@cat-factory/server 0.159.0 → 0.161.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,901 @@
1
+ // The mothership-mode persistence ALLOW-LIST — the default-deny table naming every repository
2
+ // method a mothership-mode node may invoke over `POST /internal/persistence`, and the scope rule
3
+ // that binds each call to an account.
4
+ //
5
+ // It lives beside the protocol + dispatcher (`rpc.ts`) rather than inside it because the two grow
6
+ // on completely different schedules: the protocol is stable, while this table is the initiative's
7
+ // living surface — every mothership slice widens it, so it is the file a reader goes to when
8
+ // asking "is X reachable from a laptop yet?". Splitting it also keeps `rpc.ts` within the
9
+ // file-size budget as the surface keeps growing.
10
+ //
11
+ // See `docs/initiatives/mothership-mode.md` for the per-repository bucket checklist, and
12
+ // `runtimes/node/test/mothership-allowlist.spec.ts` for the drift guard that fails unless EVERY
13
+ // Drizzle repository method is either listed here or explicitly classified.
14
+ /**
15
+ * The mothership-mode persistence allow-list: the core domain repositories plus the
16
+ * workspace-scoped reads a board load (`GET /workspaces/:id`) and an execution exercise.
17
+ * Every method here binds to an account via its {@link ScopeRule} so a call outside the
18
+ * machine token's scope is refused as 404.
19
+ *
20
+ * The cross-service board-composition reads keyed on `serviceIds[]`/`accountId`
21
+ * (`listByServices`, `serviceRepository.listByIds`/`listByAccount`, `countByServiceIds`) and the
22
+ * entity-id-keyed `blockRepository.findById` are allow-listed here too, each bound by the
23
+ * {@link ScopeRule} `serviceList` / `block` / `account` kinds that resolve the entity's owning
24
+ * account server-side before the scope check.
25
+ *
26
+ * Still EXCLUDED (added in later gate slices, with their own scope rules, or kept
27
+ * mothership-internal):
28
+ * - `subscriptionActivationRepository.deleteByExecution` — the activation row is the local
29
+ * `node:sqlite` bucket (per the per-repo checklist), not the remote surface, so it is not
30
+ * exposed here.
31
+ * - Global sweeper methods (`listStale`, `deleteOlderThan`) and high-impact unscoped ops
32
+ * (`workspaceRepository.delete`, `accountRepository.create`).
33
+ *
34
+ * Admin-gated mutations are also EXCLUDED here. The RPC dispatches over the raw repository,
35
+ * bypassing the service layer that normally enforces per-user role checks — e.g.
36
+ * `AccountService.requireAdmin` guards `accountRepository.rename`/`updateSettings` and
37
+ * `membershipRepository.upsert`/`remove`. A machine token is scoped to whole ACCOUNTS, not to
38
+ * a role within them, so exposing those repo methods would let any account member self-promote
39
+ * to admin or rewrite memberships over the wire. They stay mothership-internal until a later
40
+ * slice adds a role dimension to the scope (or routes them through the service). Only the
41
+ * account/membership READS a board load needs are remotely callable. Board-level mutations
42
+ * (`workspaceRepository.rename`/`setDescription`, block/pipeline/execution CRUD) are
43
+ * member-level in the service layer, so they remain. The board mutations that stay OUT are
44
+ * `workspaceRepository.setAccessMode` (the access-mode flip) and `linkAccount` (the legacy-board
45
+ * auto-heal that adopts a board into an account) — both are `members.manage` (admin-tier), so like
46
+ * the account/membership admin mutations they must not be reachable over the role-blind machine RPC.
47
+ */
48
+ export const REMOTE_PERSISTENCE_METHODS = {
49
+ workspaceRepository: {
50
+ listVisible: { scope: { kind: 'visibility', arg: 0 } },
51
+ get: { scope: { kind: 'workspace', arg: 0 } },
52
+ ownerOf: { scope: { kind: 'workspace', arg: 0 } },
53
+ accountOf: { scope: { kind: 'workspace', arg: 0 } },
54
+ // The workspace-RBAC authorization read (the narrow access row that replaces `accountOf`
55
+ // in the gate); workspace-scoped and secret-free, exactly like `accountOf`/`ownerOf`.
56
+ accessRowOf: { scope: { kind: 'workspace', arg: 0 } },
57
+ rename: { scope: { kind: 'workspace', arg: 0 } },
58
+ setDescription: { scope: { kind: 'workspace', arg: 0 } },
59
+ },
60
+ blockRepository: {
61
+ listByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
62
+ get: { scope: { kind: 'workspace', arg: 0 } },
63
+ insert: { scope: { kind: 'workspace', arg: 0 } },
64
+ update: { scope: { kind: 'workspace', arg: 0 } },
65
+ setService: { scope: { kind: 'workspace', arg: 0 } },
66
+ deleteMany: { scope: { kind: 'workspace', arg: 0 } },
67
+ // Entity-id-keyed (no workspace arg): resolve the block's home workspace's account server-side.
68
+ findById: { scope: { kind: 'block', arg: 0 } },
69
+ // The batched form (the cross-workspace dependency resolution on the run-start path).
70
+ findByIds: { scope: { kind: 'blockList', arg: 0 } },
71
+ // Cross-service: compose a board's blocks from every service it mounts.
72
+ listByServices: { scope: { kind: 'serviceList', arg: 0 } },
73
+ // One bounded page of a service frame's task subtree (the public API's paginated task list).
74
+ listServiceTasks: { scope: { kind: 'workspace', arg: 0 } },
75
+ // The public API's in-flight concurrency backstop (`BoardService.countActiveInternalTasks`),
76
+ // checked before a headless "initiative" run starts so a leaked key can't spin up unbounded
77
+ // LLM work. A workspace-scoped SQL COUNT returning a NUMBER — no row content crosses the
78
+ // wire. Completes the headless surface whose paginated reads (`listServiceTasks` above,
79
+ // `executionRepository.listInternal`) are already remote: without it the cap read throws and
80
+ // the mothership-mode node refuses every public-API run start.
81
+ countActiveInternal: { scope: { kind: 'workspace', arg: 0 } },
82
+ },
83
+ pipelineRepository: {
84
+ listByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
85
+ get: { scope: { kind: 'workspace', arg: 0 } },
86
+ insert: { scope: { kind: 'workspace', arg: 0 } },
87
+ update: { scope: { kind: 'workspace', arg: 0 } },
88
+ delete: { scope: { kind: 'workspace', arg: 0 } },
89
+ },
90
+ executionRepository: {
91
+ listByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
92
+ // Lean live-run projection backing the dispatch guard + resumePaused (workspace-scoped read).
93
+ listLive: { scope: { kind: 'workspace', arg: 0 } },
94
+ get: { scope: { kind: 'workspace', arg: 0 } },
95
+ getByBlock: { scope: { kind: 'workspace', arg: 0 } },
96
+ upsert: { scope: { kind: 'workspace', arg: 0 }, revWriteBack: 1 },
97
+ // The one-live-run-per-block insert used by start/retry/restart. Workspace-scoped like
98
+ // upsert and bumps `execution.rev` in place on the arg-1 instance on a successful insert.
99
+ insertLive: { scope: { kind: 'workspace', arg: 0 }, revWriteBack: 1 },
100
+ compareAndSwap: { scope: { kind: 'workspace', arg: 0 }, revWriteBack: 1 },
101
+ deleteByBlock: { scope: { kind: 'workspace', arg: 0 } },
102
+ markFailed: { scope: { kind: 'workspace', arg: 0 } },
103
+ // Cross-service: compose a board's runs from every service it mounts.
104
+ listByServices: { scope: { kind: 'serviceList', arg: 0 } },
105
+ // One bounded page of the workspace's headless (`internal`-anchored) runs — the public API's
106
+ // job list. Workspace-scoped like every other list read here.
107
+ listInternal: { scope: { kind: 'workspace', arg: 0 } },
108
+ },
109
+ accountRepository: {
110
+ // Reads only — `rename`/`updateSettings` are admin-gated (see allow-list note above).
111
+ get: { scope: { kind: 'account', arg: 0 } },
112
+ listByIds: { scope: { kind: 'accountList', arg: 0 } },
113
+ findPersonalByUser: { scope: { kind: 'selfUser', arg: 0 } },
114
+ },
115
+ membershipRepository: {
116
+ // Reads only — `upsert`/`remove` are admin-gated (see allow-list note above).
117
+ listByUser: { scope: { kind: 'selfUser', arg: 0 } },
118
+ listByAccount: { scope: { kind: 'account', arg: 0 } },
119
+ get: { scope: { kind: 'account', arg: 0 } },
120
+ },
121
+ // The workspace-RBAC member-tier READS the gate + list path run on every signed request
122
+ // (workspace-rbac slice 3). `get` is the gate's per-request effective-role read — workspace-
123
+ // scoped and secret-free, exactly like `workspaceRepository.accessRowOf`.
124
+ // `getRolesForUserInWorkspaces` is the `GET /workspaces` list-annotation batch read; it is
125
+ // pinned to the CALLER's own id (`selfUser`), so it can only ever return the caller's own
126
+ // membership roles (a board they hold no row in is simply absent — no existence leak), and it
127
+ // returns a serializable `Record` so it round-trips over this RPC. The roster read
128
+ // (`listByWorkspace`/`listWorkspaceIdsForUser`) + the writes (`upsert`/`remove`/
129
+ // `removeByAccountMembership`) stay mothership-internal — the member-management API is a later
130
+ // slice, and the writes are admin-gated (the machine token is role-blind).
131
+ workspaceMemberRepository: {
132
+ get: { scope: { kind: 'workspace', arg: 0 } },
133
+ getRolesForUserInWorkspaces: { scope: { kind: 'selfUser', arg: 0 } },
134
+ },
135
+ // --- Member-display read surface ------------------------------------------------
136
+ // The user DISPLAY records the account members panel enriches its roster with
137
+ // (`AccountService.members` → `userRepository.listByIds(memberIds)`) and the single-user display
138
+ // lookup (`get`). These carry only the presentational `UserRecord` (id / name / email / avatarUrl
139
+ // / createdAt) — NOT the password `secret`, which lives on `UserIdentityRecord` and is reachable
140
+ // only via `getIdentity`/`listIdentities` (kept off, like the other identity/auth reads). So the
141
+ // display reads leak no credential and are safe to proxy.
142
+ //
143
+ // Scope: a userId is not itself an account/workspace, so it is bound by CO-MEMBERSHIP — the `user`
144
+ // rule (single id) / `userList` rule (batch) admit a user iff they are a member of one of the
145
+ // token's in-scope accounts, resolved server-side from the account rosters. The roster read only
146
+ // ever passes ids that ARE members of the (in-scope) account it just listed, so the batch check
147
+ // always passes on the real path; a forged out-of-scope id fails closed (404, no existence leak).
148
+ // The `update` write (profile edit) + the identity/auth reads (`findByIdentity`/`findByEmail`/
149
+ // `getIdentity`/`listIdentities`) stay off — they are the account-lifecycle / login surface, not
150
+ // member display, and the identity reads carry the password secret.
151
+ userRepository: {
152
+ get: { scope: { kind: 'user', arg: 0 } },
153
+ listByIds: { scope: { kind: 'userList', arg: 0 } },
154
+ },
155
+ // --- Board-load read surface --------------------------------------------------
156
+ // The workspace-scoped reads a `GET /workspaces/:id` snapshot assembles. Each takes the
157
+ // workspaceId as arg0, so they reuse the `workspace` rule (resolve the owning account, reject
158
+ // out-of-scope as 404). Reads only — no mutation is exposed here.
159
+ //
160
+ // The cross-service reads (`*.listByServices`, `countByServiceIds`, `serviceRepository.*`)
161
+ // compose a board from the services it mounts; their arg0 is `serviceIds[]` (the `serviceList`
162
+ // rule resolves each service's owning account) or an `accountId` (the `account` rule).
163
+ serviceRepository: {
164
+ listByIds: { scope: { kind: 'serviceList', arg: 0 } },
165
+ listByAccount: { scope: { kind: 'account', arg: 0 } },
166
+ // The run path resolves the service that owns a frame block (module materialisation /
167
+ // blueprint reconcile). arg0 is a frame BLOCK id, so the `block` rule resolves it to its
168
+ // home workspace's account server-side.
169
+ getByFrameBlock: { scope: { kind: 'block', arg: 0 } },
170
+ // The batched form of `getByFrameBlock` — the board-composition read that resolves every
171
+ // frame's service in ONE query (the duplicate-service check when linking a monorepo, and the
172
+ // frame-subtree deletion cleanup in `BoardService`). arg0 is a `frameBlockIds[]` array, so the
173
+ // `blockList` rule resolves each frame block's home workspace's account server-side and fails
174
+ // closed on any missing/out-of-scope id (empty input → empty). The remaining service CRUD +
175
+ // `getByRepo` (the GitHub-sync repo→service link) stay off the SPA path — a later slice.
176
+ listByFrameBlocks: { scope: { kind: 'blockList', arg: 0 } },
177
+ // The org-catalog mount flow reads a single service by id before mounting it onto a board
178
+ // (`ServiceMountService.mount` — the cross-org guard that a service is mounted only within
179
+ // its own account). arg0 is a serviceId with no workspace arg, so the `service` rule resolves
180
+ // its owning account server-side.
181
+ get: { scope: { kind: 'service', arg: 0 } },
182
+ },
183
+ // --- Shared-service mount management surface -------------------------------------
184
+ // The org-catalog / shared-service mounting flow a mothership-mode SPA drives
185
+ // (`ServiceMountService` / `ServiceMountController`): mount / unmount / re-layout a shared
186
+ // account service onto a workspace board. The reads that compose the catalog badge
187
+ // (`listByWorkspace`, `countByServiceIds`) were already exposed; these complete the write
188
+ // surface. `get`/`update`/`remove` take the workspaceId as arg0 (the `workspace` rule); the
189
+ // record-based `upsert(mount)` binds on the mount's `workspaceId` FIELD via the `serviceMount`
190
+ // rule. Each is member-level (the mount endpoints are not admin-gated) and workspace-scoped.
191
+ //
192
+ // Cross-org sharing stays enforced at the RPC layer, NOT only in the (bypassed) service layer:
193
+ // the `serviceMount` rule additionally requires the mounted `serviceId` to be owned by the SAME
194
+ // account as the target workspace, so a raw `upsert` can never plant a cross-org mount — even
195
+ // for a machine token that spans several accounts (a user in multiple orgs). Board composition
196
+ // (`blockRepository.listByServices`, `serviceRepository.listByIds`) stays account-scoped as a
197
+ // second line of defence, but it is no longer the sole guard for the mount invariant.
198
+ workspaceMountRepository: {
199
+ listByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
200
+ countByServiceIds: { scope: { kind: 'serviceList', arg: 0 } },
201
+ // The real-time fan-out's hot read, and the one method on this table that is NOT a management
202
+ // surface: `FanOutEventPublisher.targets` calls it on EVERY engine event publish to expand the
203
+ // changed block to the set of boards mounting its service. It is therefore load-bearing for
204
+ // mothership mode in a way the others aren't — a mothership-mode node wires the same fan-out
205
+ // decorator, so leaving it off meant every publish rejected with `unknown_method` and the
206
+ // rejection propagated into the run-state emit (`RunStateMachine`), not just into a missed
207
+ // frame. Also drives the mount/unmount live-update of OTHER boards showing the same service
208
+ // (`BoardService`), which was the known gap the mount-management slice left open.
209
+ //
210
+ // arg0 is the ORIGIN workspaceId, so the plain `workspace` rule binds it. The join starts from
211
+ // that in-scope workspace's block and returns workspace IDS only — no row content — and a
212
+ // service can only ever be mounted inside its own account (the `serviceMount` rule makes that
213
+ // non-bypassable), so the result set can never span an account the token doesn't hold.
214
+ listWorkspaceIdsMountingBlock: { scope: { kind: 'workspace', arg: 0 } },
215
+ get: { scope: { kind: 'workspace', arg: 0 } },
216
+ upsert: { scope: { kind: 'serviceMount', arg: 0 } },
217
+ update: { scope: { kind: 'workspace', arg: 0 } },
218
+ remove: { scope: { kind: 'workspace', arg: 0 } },
219
+ },
220
+ workspaceSettingsRepository: {
221
+ get: { scope: { kind: 'workspace', arg: 0 } },
222
+ // The workspace-settings panel saves its edits (e.g. the `storeAgentContext` toggle). The
223
+ // settings endpoints are member-level (not admin-gated), workspace-scoped — the same policy
224
+ // as the block/pipeline mutations above. Completes the read+write settings surface.
225
+ upsert: { scope: { kind: 'workspace', arg: 0 } },
226
+ },
227
+ // Per-user settings (the user-tier spend budget). Self-scoped: a user reads/writes only their
228
+ // OWN row (the `selfUser` rule requires args[0] to equal the token's userId), so both the
229
+ // read (snapshot + spend gate) and the write (the user's own budget edit) are safe over RPC —
230
+ // no admin gating is involved, unlike the account-tier budget (see accountRepository note).
231
+ // Invariant: the user-tier gate/snapshot always passes the CALLER's own userId here — a run's
232
+ // initiator is the mothership laptop's signed-in user (single-user token), and the snapshot
233
+ // passes the viewer's id — so `selfUser` matches by construction. If that ever diverged the
234
+ // read would be denied (404 → the remote proxy throws); the snapshot assembly reads these
235
+ // best-effort (degrading the tier to absent) so a scope mismatch can't 500 the board load.
236
+ userSettingsRepository: {
237
+ get: { scope: { kind: 'selfUser', arg: 0 } },
238
+ upsert: { scope: { kind: 'selfUser', arg: 0 } },
239
+ },
240
+ riskPolicyRepository: {
241
+ list: { scope: { kind: 'workspace', arg: 0 } },
242
+ // The merge lifecycle resolves a task's merge-threshold preset at run time
243
+ // (`resolveRiskPolicy` → the merger/requirements gate), reading the workspace default when
244
+ // the task pins none. Workspace-scoped read on the run path.
245
+ getDefault: { scope: { kind: 'workspace', arg: 0 } },
246
+ // `RiskPolicyService.list` lazily seeds the built-in default for a workspace that has
247
+ // none (a write triggered by the board-load read). Member-level (the preset CRUD is not
248
+ // admin-gated), workspace-scoped — the same policy as the block/pipeline mutations above.
249
+ upsert: { scope: { kind: 'workspace', arg: 0 } },
250
+ // The preset-library editor reads one preset and deletes it. Both take the workspaceId as
251
+ // arg0 and are member-level (the preset CRUD is not admin-gated), completing the merge-preset
252
+ // library management surface (list/getDefault/upsert were already exposed for the board load).
253
+ get: { scope: { kind: 'workspace', arg: 0 } },
254
+ remove: { scope: { kind: 'workspace', arg: 0 } },
255
+ },
256
+ // The merge TRACK RECORD is the evidence side of the same merge policy, and every one of its
257
+ // methods takes the workspaceId as arg0 — so the whole surface is proxied, workspace-scoped and
258
+ // member-level exactly like the preset library above. It has to be: `MergeResolver` reads the
259
+ // classification and writes the record ON THE RUN PATH, so a mothership-mode node with these
260
+ // unproxied would resolve every per-class rule against an empty record set (silently reverting
261
+ // to the score ceilings) and lose every merge decision it made.
262
+ mergeTrackRecordRepository: {
263
+ // Run path: the merger step's decision write (first-write-wins) + the notification card's
264
+ // record lookup.
265
+ insertIfAbsent: { scope: { kind: 'workspace', arg: 0 } },
266
+ get: { scope: { kind: 'workspace', arg: 0 } },
267
+ getByExecution: { scope: { kind: 'workspace', arg: 0 } },
268
+ // The block-scoped merge controls resolve a block's most recent record to settle + tag it.
269
+ getLatestByBlock: { scope: { kind: 'workspace', arg: 0 } },
270
+ // External-merge attribution from the webhook ingest, keyed by `(repoId, prNumber)`.
271
+ getByPullRequest: { scope: { kind: 'workspace', arg: 0 } },
272
+ // Settling a decision + recording the reviewer-effort tag.
273
+ patch: { scope: { kind: 'workspace', arg: 0 } },
274
+ // The preset editor's per-class stats (ONE aggregate for every class).
275
+ rollupByClass: { scope: { kind: 'workspace', arg: 0 } },
276
+ },
277
+ // Shared stacks are a workspace-scoped, member-level config library (like merge presets): the
278
+ // Infrastructure panel lists/creates/edits/deletes them and the board-load snapshot reads them.
279
+ // All four repository methods take the workspaceId as arg0 — proxied to the mothership like the
280
+ // other workspace libraries. (The bring-up/teardown LIFECYCLE is a host-Docker service action,
281
+ // not a repository method, so it never crosses the machine API.)
282
+ sharedStackRepository: {
283
+ list: { scope: { kind: 'workspace', arg: 0 } },
284
+ get: { scope: { kind: 'workspace', arg: 0 } },
285
+ upsert: { scope: { kind: 'workspace', arg: 0 } },
286
+ remove: { scope: { kind: 'workspace', arg: 0 } },
287
+ },
288
+ modelPresetRepository: {
289
+ list: { scope: { kind: 'workspace', arg: 0 } },
290
+ // The run-start model resolution (`resolvePresetModelForKind` → the personal-credential
291
+ // gate) reads the workspace's default model preset for the dispatched agent kind.
292
+ getDefault: { scope: { kind: 'workspace', arg: 0 } },
293
+ // `ModelPresetService.list` lazily seeds the built-in defaults for a workspace that has none
294
+ // (a write the board-load read triggers), exactly like `riskPolicyRepository.upsert` above.
295
+ upsert: { scope: { kind: 'workspace', arg: 0 } },
296
+ // The model-preset library editor's read-one + delete, the mirror of the merge-preset
297
+ // management pair above. Member-level, workspace-scoped.
298
+ get: { scope: { kind: 'workspace', arg: 0 } },
299
+ remove: { scope: { kind: 'workspace', arg: 0 } },
300
+ },
301
+ // --- Agent-context run-path reads -----------------------------------------------
302
+ // `AgentContextBuilder` resolves a block's LINKED docs/tasks for EVERY container agent step
303
+ // (it builds the agent context on each dispatch), so these reads are on the run path, not just
304
+ // the opt-in document/task integrations' own surfaces. arg0 is the workspaceId → `workspace`
305
+ // rule. The document/task SOURCE-PROVIDER + connection surfaces (connect/list/disconnect) are
306
+ // NOT exposed here — they are a later integration slice; only the block-scoped context reads are.
307
+ documentRepository: {
308
+ listByBlock: { scope: { kind: 'workspace', arg: 0 } },
309
+ get: { scope: { kind: 'workspace', arg: 0 } },
310
+ // A URL named in a block's description is resolved against the imported corpus by a
311
+ // canonical-url point lookup (`AgentContextBuilder.resolveLinkedContext`), on the SAME
312
+ // per-dispatch run path as `get`/`listByBlock` above — so it must be allow-listed too
313
+ // (else a task whose description contains any link fails the run with `unknown_method`).
314
+ getByUrl: { scope: { kind: 'workspace', arg: 0 } },
315
+ // Document-authoring run path (WS1): for a doc-aware kind, `AgentContextBuilder` resolves the
316
+ // workspace's linked TEMPLATE (singular) + EXEMPLAR (list) for the block's `docKind` on each
317
+ // dispatch, so both reads are on the run path exactly like `listByBlock`/`getByUrl`. arg0 is
318
+ // the workspaceId → the `workspace` rule. (The role-link WRITE surface + the whole-workspace
319
+ // list back the management UI, not the run path — they stay mothership-internal for now.)
320
+ getRoleLink: { scope: { kind: 'workspace', arg: 0 } },
321
+ listRoleLinks: { scope: { kind: 'workspace', arg: 0 } },
322
+ },
323
+ taskRepository: {
324
+ listByBlock: { scope: { kind: 'workspace', arg: 0 } },
325
+ get: { scope: { kind: 'workspace', arg: 0 } },
326
+ // Same as `documentRepository.getByUrl`: a URL in the description resolves against the
327
+ // imported issue corpus by a point lookup on the run path.
328
+ getByUrl: { scope: { kind: 'workspace', arg: 0 } },
329
+ // The batched counterpart to `get`: `AgentContextBuilder.resolveLinkedContext` resolves the
330
+ // tracker issues a block's description names (Jira keys, `owner/repo#N` refs) in ONE
331
+ // chunked-`IN` read. It is invoked UNCONDITIONALLY on every container-agent dispatch (the
332
+ // call isn't guarded on there being any refs), so it is on the run path exactly like `get`
333
+ // — omit it and EVERY such build fails the run with `unknown_method`. arg0 is the
334
+ // workspaceId → the `workspace` rule.
335
+ listByRefs: { scope: { kind: 'workspace', arg: 0 } },
336
+ },
337
+ // The agent context also resolves the block's provisioned environment per step
338
+ // (`resolveForBlock`/`get`, both workspace-keyed). Reads only — the connect/provision surface
339
+ // (and decrypting a remotely-sealed env cipher, which needs the mothership's key) is a later slice.
340
+ environmentRegistryRepository: {
341
+ getByBlock: { scope: { kind: 'workspace', arg: 0 } },
342
+ // The per-`(block, service frame)` discovery read. `AgentContextBuilder.resolveEnvironment`
343
+ // (and `RunDispatcher.attachEnvironmentProjection`) resolve the OWN service frame's env by
344
+ // frame on EVERY container-agent dispatch, so this is on the run path exactly like `getByBlock`
345
+ // — omit it and every such build throws `unknown_method`.
346
+ getByBlockAndFrame: { scope: { kind: 'workspace', arg: 0 } },
347
+ // The frame-less (manual / human-test) fallback behind `readRegistryRecord` — on the same
348
+ // container-agent run path as `getByBlockAndFrame` (the own-frame env resolution falls back to
349
+ // it), so omit it and every such build throws `unknown_method`.
350
+ getFramelessByBlock: { scope: { kind: 'workspace', arg: 0 } },
351
+ get: { scope: { kind: 'workspace', arg: 0 } },
352
+ // The workspace-scoped batch read behind `EnvironmentProvisioningService.listHandles`
353
+ // (the environments list endpoint + the frontend UI-test gate's single indexed env read,
354
+ // `AgentContextBuilder.resolveFrontendConfig` — a batch read, not a per-binding point read).
355
+ listByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
356
+ },
357
+ // --- Ephemeral-environment backend connection management surface ----------------
358
+ // The environment provider-connection + per-type infra-handler management panels a mothership-mode
359
+ // SPA drives (`EnvironmentController` → `EnvironmentConnectionService`: connect / list / disconnect
360
+ // a backend, and register / test / re-secret / unregister a per-type engine handler). Its
361
+ // controller mounts under `/workspaces/:workspaceId` and is member-level (not admin-gated), so it
362
+ // follows the same policy as the observability / other settings panels above. Reads/deletes take
363
+ // the workspaceId as arg0 (the `workspace` rule); the record-based `upsert(record)` binds on the
364
+ // record's `workspaceId` FIELD (the `workspaceField` rule — the id is a property, not a positional
365
+ // arg). Exposing these makes the environment-connection settings panels functional (persist +
366
+ // read back the redacted summary) in mothership mode.
367
+ //
368
+ // Safe to expose like the observability connection above: the connection record carries the
369
+ // handler secrets as a SEALED blob (`secretsCipher`) — the repo returns it verbatim (it does NOT
370
+ // decrypt); sealing/decryption live in `EnvironmentConnectionService` under the LOCAL key, so no
371
+ // plaintext credential crosses the machine API and the mothership only ever stores ciphertext (the
372
+ // initiative's "the mothership ENCRYPTION_KEY never reaches the laptop" split holds). What this
373
+ // does NOT yet unlock: actually PROVISIONING an environment in mothership mode — the registry
374
+ // WRITE path (`environmentRegistryRepository.insert`/`update`) + decrypting a remotely-sealed
375
+ // access cipher stay off, the later secrets-delegation slice, exactly like the observability gate
376
+ // probe. The `workspaceField` rule binds only the record's top-level `workspaceId` (see its note
377
+ // above), so a connection row can only ever land in the caller's own in-scope workspace.
378
+ environmentConnectionRepository: {
379
+ listByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
380
+ getByWorkspaceAndType: { scope: { kind: 'workspace', arg: 0 } },
381
+ upsert: { scope: { kind: 'workspaceField', arg: 0 } },
382
+ softDelete: { scope: { kind: 'workspace', arg: 0 } },
383
+ },
384
+ // The workspace-defined custom-manifest-type catalog the infra configurator reads + edits
385
+ // (`EnvironmentConnectionService.listCustomTypes`/`upsertCustomType`/`removeCustomType`, merged
386
+ // with the deployment's registered code types for display). Rows carry NO secrets — just manifest
387
+ // metadata — so the whole CRUD surface is remote. `listByWorkspace`/`remove` take the workspaceId
388
+ // as arg0 (the `workspace` rule); the record-based `upsert(record)` binds on the record's
389
+ // `workspaceId` FIELD (the `workspaceField` rule). Member-level, workspace-scoped — the same policy
390
+ // as the connection surface above, and it completes the environments management panel (the
391
+ // `listHandlers` bundle loads both the connection handlers AND this catalog).
392
+ customManifestTypeRepository: {
393
+ listByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
394
+ upsert: { scope: { kind: 'workspaceField', arg: 0 } },
395
+ remove: { scope: { kind: 'workspace', arg: 0 } },
396
+ },
397
+ serviceFragmentDefaultsRepository: {
398
+ get: { scope: { kind: 'workspace', arg: 0 } },
399
+ // The service-fragment-defaults editor saves the workspace's default fragment set. Member-level,
400
+ // workspace-scoped — completes the read+write surface (`get` was exposed for the board load).
401
+ set: { scope: { kind: 'workspace', arg: 0 } },
402
+ },
403
+ pipelineScheduleRepository: {
404
+ list: { scope: { kind: 'workspace', arg: 0 } },
405
+ getByBlock: { scope: { kind: 'workspace', arg: 0 } },
406
+ listByServices: { scope: { kind: 'serviceList', arg: 0 } },
407
+ // Recurring-pipeline management, all driven by the local node's `RecurringPipelineController`
408
+ // → `RecurringPipelineService` (CRUD + run history + `runNow`). Every method takes the
409
+ // workspaceId as arg0 and is member-level (the schedule endpoints are not admin-gated).
410
+ // `runNow` fires the schedule in-process, so its `fire()` writes (`insertRun`/`updateRun`/
411
+ // `upsert`) are on the path too — the sweeper-only `listDue`/`pruneRunsBefore` stay
412
+ // mothership-internal (its cron owns them). Completes the schedule management surface (the
413
+ // `list`/`getByBlock`/`listByServices` reads were already exposed).
414
+ get: { scope: { kind: 'workspace', arg: 0 } },
415
+ upsert: { scope: { kind: 'workspace', arg: 0 } },
416
+ remove: { scope: { kind: 'workspace', arg: 0 } },
417
+ insertRun: { scope: { kind: 'workspace', arg: 0 } },
418
+ updateRun: { scope: { kind: 'workspace', arg: 0 } },
419
+ listRuns: { scope: { kind: 'workspace', arg: 0 } },
420
+ },
421
+ trackerSettingsRepository: {
422
+ get: { scope: { kind: 'workspace', arg: 0 } },
423
+ // The tracker-settings editor persists its config. Member-level, workspace-scoped — completes
424
+ // the read+write surface (`get` was exposed for the board load).
425
+ put: { scope: { kind: 'workspace', arg: 0 } },
426
+ },
427
+ notificationRepository: {
428
+ listOpen: { scope: { kind: 'workspace', arg: 0 } },
429
+ // The inbox act/dismiss/escalate flow re-reads a single notification by id after a run
430
+ // settles (`NotificationService`). `get(workspaceId, id)` is workspace-scoped on arg0.
431
+ get: { scope: { kind: 'workspace', arg: 0 } },
432
+ // The merger-less pipeline tail raises a block notification on completion
433
+ // (`pipeline_complete`/`merge_review` → `findOpenByBlock` dedup + `upsertOpenForBlock`), so a
434
+ // run persists its inbox card on the mothership. Workspace-scoped, member-level (the inbox
435
+ // act/dismiss endpoints are not admin-gated) — the same policy as the block/pipeline writes.
436
+ findOpenByBlock: { scope: { kind: 'workspace', arg: 0 } },
437
+ // The block-less dedup lookup for deployment/workspace-wide cards (`platform_health`). A
438
+ // local node runs the platform-health sweep too, so it proxies this like `findOpenByBlock`.
439
+ // Workspace-scoped, member-level — same policy as the reads above.
440
+ findOpenByType: { scope: { kind: 'workspace', arg: 0 } },
441
+ upsertOpenForBlock: { scope: { kind: 'workspace', arg: 0 } },
442
+ // Block-less raises (a card with no `blockId`) and every status transition the inbox
443
+ // performs right after a run settles — act / dismiss / escalate — go through `upsert`
444
+ // (`NotificationService`), not `upsertOpenForBlock`. Workspace-scoped, member-level (the
445
+ // inbox act/dismiss endpoints are not admin-gated) — same policy as the writes above.
446
+ upsert: { scope: { kind: 'workspace', arg: 0 } },
447
+ // The inbox `act` flow atomically claims the open card (`open` → `acted`) BEFORE running
448
+ // its side effect, so a mothership node must proxy the claim like the surrounding
449
+ // get/upsert. Workspace-scoped, member-level — same policy as `upsert`.
450
+ claimForAction: { scope: { kind: 'workspace', arg: 0 } },
451
+ // The escalation sweep's batched write (a local node runs the sweep too, so it must proxy
452
+ // like the listOpen + per-row upsert loop it replaced). Workspace-scoped like `upsert`.
453
+ escalateStaleOpen: { scope: { kind: 'workspace', arg: 0 } },
454
+ },
455
+ // --- Repo-bootstrap management / retry / stop surface ---------------------------
456
+ // The bootstrap flow a mothership-mode SPA drives (`BootstrapController` +
457
+ // `AgentRunController`): start a repo bootstrap, read a single job (the board-card poll), and
458
+ // retry / stop a failed or running one. The board-load reads (`listByWorkspace` /
459
+ // `listByServices`) were already exposed; these complete the surface. `get`/`update` take the
460
+ // workspaceId as arg0 (the `workspace` rule); the record-based `insert(record)` binds on the
461
+ // job's `workspaceId` FIELD (the `workspaceField` rule — the id is a property, not a positional
462
+ // arg). Each is member-level (the bootstrap endpoints are not admin-gated) and workspace-scoped —
463
+ // the same policy as the block/pipeline mutations. The `insert` record's sibling ids (`blockId`,
464
+ // `referenceArchitectureId`) are NOT re-validated over the RPC (see the `workspaceField` note):
465
+ // the row is stored under — and later read by — the bound `workspaceId`, and a foreign
466
+ // `referenceArchitectureId` is harmless because the retry run re-resolves it via the
467
+ // workspace-scoped `referenceArchitectureRepository.get` below, which 404s a cross-workspace id.
468
+ bootstrapJobRepository: {
469
+ listByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
470
+ listByServices: { scope: { kind: 'serviceList', arg: 0 } },
471
+ get: { scope: { kind: 'workspace', arg: 0 } },
472
+ insert: { scope: { kind: 'workspaceField', arg: 0 } },
473
+ update: { scope: { kind: 'workspace', arg: 0 } },
474
+ },
475
+ // The reference-architecture library the bootstrap modal reads + edits, and that a retry
476
+ // re-resolves the base repo from (`referenceArchitectureRepository.get`). Reads/updates/deletes
477
+ // take the workspaceId as arg0 (the `workspace` rule); the record-based `insert(record)` binds on
478
+ // the record's `workspaceId` FIELD (the `workspaceField` rule). Member-level (the reference-arch
479
+ // endpoints are not admin-gated), workspace-scoped — the same policy as the other library editors.
480
+ referenceArchitectureRepository: {
481
+ get: { scope: { kind: 'workspace', arg: 0 } },
482
+ listByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
483
+ insert: { scope: { kind: 'workspaceField', arg: 0 } },
484
+ update: { scope: { kind: 'workspace', arg: 0 } },
485
+ softDelete: { scope: { kind: 'workspace', arg: 0 } },
486
+ },
487
+ // The board's run controls (retry / stop a failed or running run) enter through the unified
488
+ // `agent_runs` table: `AgentRunController` calls `getRef(workspaceId, id)` to resolve the run's
489
+ // KIND, then dispatches to the matching service. `getRef` takes the workspaceId as arg0, so it
490
+ // reuses the `workspace` rule (resolve the owning account, reject out-of-scope as 404). Exposing
491
+ // it makes the EXECUTION-run retry/stop path functional in mothership mode — every downstream
492
+ // read+write those services make (`executionRepository.get/deleteByBlock/upsert/markFailed`,
493
+ // `blockRepository.update`, `pipelineRepository.get`, the budget/binary-storage prechecks) is
494
+ // already allow-listed on the run/start path. The bootstrap + env-config-repair retry branches
495
+ // read their own repos (`bootstrapJobRepository.get`, `referenceArchitectureRepository.get`, …),
496
+ // now allow-listed too (see the bootstrap / reference-architecture / env-config-repair management
497
+ // surface above). The sweeper-only `listStale`/`liveRunIds` stay mothership-internal (its cron
498
+ // owns them).
499
+ agentRunRepository: {
500
+ getRef: { scope: { kind: 'workspace', arg: 0 } },
501
+ },
502
+ tokenUsageRepository: {
503
+ totalsSinceForWorkspace: { scope: { kind: 'workspace', arg: 0 } },
504
+ // The usage report (Usage settings tab) — one workspace-scoped GROUP BY read, same
505
+ // scoping as the workspace spend rollup above.
506
+ usageBreakdownForWorkspace: { scope: { kind: 'workspace', arg: 0 } },
507
+ // Account/user budget-tier rollups (docs/initiatives/tiered-budgets.md), read on the spend
508
+ // gate + the snapshot. Account-scoped and self-user-scoped respectively, mirroring the
509
+ // account read + the per-user settings read above. (Metered WRITEs — `record` — stay out of
510
+ // the allow-list like all high-volume telemetry writes.)
511
+ totalsSinceForAccount: { scope: { kind: 'account', arg: 0 } },
512
+ totalsSinceForUser: { scope: { kind: 'selfUser', arg: 0 } },
513
+ },
514
+ // Telemetry is local-first by design (Phase 5), but two READS are on the synchronous run
515
+ // path before that batch-sync lands — the kaizen grading step summarises an execution's LLM
516
+ // calls. Until Phase 5 they resolve against the mothership's telemetry store. High-volume
517
+ // telemetry WRITES (`record`) stay out of the allow-list — they must never hit the RPC.
518
+ llmCallMetricRepository: {
519
+ summarizeByExecution: { scope: { kind: 'workspace', arg: 0 } },
520
+ },
521
+ // Kaizen grading (the merge lifecycle's quality step) reads its prior grade for a step before
522
+ // (re-)grading and writes the result. Both are workspace-scoped on arg0; the sweeper methods
523
+ // (`listPending`/`claim`) stay mothership-internal.
524
+ //
525
+ // The Kaizen SCREEN read surface is exposed too, so a mothership-mode SPA can display the
526
+ // grading history + per-run grading status (`KaizenController` → `KaizenService.getOverview` /
527
+ // `listForExecution`, both member-level, read-only, mounted under `/workspaces/:workspaceId`):
528
+ // `listByWorkspace(workspaceId, limit?)` (the screen's bounded history) and
529
+ // `listByExecution(workspaceId, executionId)` (the run-window per-step status). Both take the
530
+ // workspaceId as arg0 (the `workspace` rule). The internal-only single-grade `get(workspaceId,
531
+ // id)` is not on any SPA path (the service never calls it), and `listPending`/`claim` are the
532
+ // background sweep's kind-spanning reads — all stay mothership-internal.
533
+ kaizenGradingRepository: {
534
+ getByStep: { scope: { kind: 'workspace', arg: 0 } },
535
+ upsert: { scope: { kind: 'workspace', arg: 0 } },
536
+ listByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
537
+ listByExecution: { scope: { kind: 'workspace', arg: 0 } },
538
+ },
539
+ // Mixed (workspaceId + blockId/stage): the workspace arg stays the scope key.
540
+ requirementReviewRepository: {
541
+ getByBlock: { scope: { kind: 'workspace', arg: 0 } },
542
+ // The requirements gate reads a review by id (`get(workspaceId, id)`) when driving the
543
+ // parked run (re-review / incorporate). Workspace-scoped on arg0.
544
+ get: { scope: { kind: 'workspace', arg: 0 } },
545
+ // The reviewer/incorporation companion persists the review as the gate iterates.
546
+ // Member-level (the requirement-review endpoints are not admin-gated), workspace-scoped.
547
+ upsert: { scope: { kind: 'workspace', arg: 0 } },
548
+ // The service drops a block's prior review before a fresh review run
549
+ // (`RequirementReviewService.review`). Workspace-scoped on arg0 — completes the repo.
550
+ deleteByBlock: { scope: { kind: 'workspace', arg: 0 } },
551
+ },
552
+ // Interactive document-interview sessions (WS5). The doc-authoring RUN PATH reads the
553
+ // converged brief (`getByBlock`, via the agent-context builder on every doc-writer dispatch),
554
+ // and the interview window reads/persists as the gate iterates. All workspace-scoped on arg0,
555
+ // mirroring the requirement-review surface.
556
+ docInterviewRepository: {
557
+ getByBlock: { scope: { kind: 'workspace', arg: 0 } },
558
+ get: { scope: { kind: 'workspace', arg: 0 } },
559
+ upsert: { scope: { kind: 'workspace', arg: 0 } },
560
+ deleteByBlock: { scope: { kind: 'workspace', arg: 0 } },
561
+ },
562
+ // The merge lifecycle's kaizen step reads any prior verified model/prompt combo
563
+ // (`getByKey(workspaceId, comboKey)`) to skip re-grading. Workspace-scoped on arg0. The Kaizen
564
+ // screen also lists the whole verified-combo library (`listByWorkspace`, part of the same
565
+ // `getOverview` read) — workspace-scoped, read-only, member-level. The sweep's `upsert` (the
566
+ // streak/verified write) stays off the SPA path — kaizen grading is best-effort in mothership
567
+ // mode until the Phase 5 telemetry/local-first sync lands.
568
+ kaizenVerifiedComboRepository: {
569
+ getByKey: { scope: { kind: 'workspace', arg: 0 } },
570
+ listByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
571
+ },
572
+ // Env-config-repair (a Tester sub-flow) lists a workspace's repair jobs on the run path
573
+ // (`listByWorkspace`), and the board's run controls retry / stop a failed or running repair run:
574
+ // `get`/`update` take the workspaceId as arg0 (the `workspace` rule), the record-based
575
+ // `insert(record)` binds on the job's `workspaceId` FIELD (the `workspaceField` rule). Retry
576
+ // STARTS a fresh run from the failed job's coords, so it reads the prior job (`get`) then inserts
577
+ // a new one; stop patches the running job (`update`). Member-level, workspace-scoped.
578
+ envConfigRepairJobRepository: {
579
+ listByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
580
+ get: { scope: { kind: 'workspace', arg: 0 } },
581
+ insert: { scope: { kind: 'workspaceField', arg: 0 } },
582
+ update: { scope: { kind: 'workspace', arg: 0 } },
583
+ },
584
+ // The ephemeral-environment SELF-TEST run store (`environment_test_runs`): a member-level,
585
+ // workspace-scoped run-path diagnostic (`EnvironmentTestService` — start / durable poll /
586
+ // stop, plus the snapshot's in-flight-runs read `listRunningByWorkspace`). The whole repo is
587
+ // remote: `get`/`updateIfRunning`/`listRunningByWorkspace` take the workspaceId as arg0 (the
588
+ // `workspace` rule); the record-based `insert(record)` binds on the run's `workspaceId` FIELD
589
+ // (the `workspaceField` rule). The sweeper-only cross-workspace `listStale` stays
590
+ // mothership-internal (its cron owns it), per the global-sweeper exclusion above. The GitHub
591
+ // half of the self-test (branch create/delete via `resolveRunRepoContext`) rides mothership
592
+ // GitHub token delegation (`/internal/github/installation-token`), not this table. What
593
+ // still gates a FULL mothership-mode self-test: the provisioning WRITES
594
+ // (`environmentRegistryRepository.insert`/`update`) stay off until the secrets-delegation
595
+ // slice, so the run's provisioning stage fails cleanly there — the store itself is proxied
596
+ // so the runs surface, clean up, and complete the moment that slice lands.
597
+ environmentTestRunRepository: {
598
+ get: { scope: { kind: 'workspace', arg: 0 } },
599
+ insert: { scope: { kind: 'workspaceField', arg: 0 } },
600
+ updateIfRunning: { scope: { kind: 'workspace', arg: 0 } },
601
+ listRunningByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
602
+ },
603
+ // --- Advanced review / structured-dialogue session surfaces ---------------------
604
+ // The clarity-review (bug-report triage), brainstorm (structured dialogue) and consensus
605
+ // (multi-strategy orchestration) windows mirror the requirements-review surface above: rows
606
+ // scoped by workspace, keyed by block/stage/step, with a live entry per block. A mothership-mode
607
+ // SPA runs and re-reads these reviews, and the services persist/replace them as the window
608
+ // iterates — every method takes the workspaceId as arg0 (the `upsert(workspaceId, review)`
609
+ // signature carries it positionally, so the `workspace` rule binds it, not `workspaceField`).
610
+ // Member-level (none of the review endpoints is admin-gated), workspace-scoped — the same policy
611
+ // as the requirement-review surface. Completes the read+write surface (`getByBlock` /
612
+ // `getByBlockStage` were already exposed for the board load).
613
+ clarityReviewRepository: {
614
+ getByBlock: { scope: { kind: 'workspace', arg: 0 } },
615
+ get: { scope: { kind: 'workspace', arg: 0 } },
616
+ upsert: { scope: { kind: 'workspace', arg: 0 } },
617
+ deleteByBlock: { scope: { kind: 'workspace', arg: 0 } },
618
+ },
619
+ brainstormSessionRepository: {
620
+ getByBlockStage: { scope: { kind: 'workspace', arg: 0 } },
621
+ get: { scope: { kind: 'workspace', arg: 0 } },
622
+ upsert: { scope: { kind: 'workspace', arg: 0 } },
623
+ deleteByBlockStage: { scope: { kind: 'workspace', arg: 0 } },
624
+ },
625
+ // Initiatives (the long-running multi-task work container): the create/read surface the
626
+ // board + tracker window use, plus the planning pipeline's ingest writes. Every method is
627
+ // workspaceId-arg0 scoped; the rev-guarded `compareAndSwap` carries the whole entity as
628
+ // arg1 with the expected rev as arg2. `listExecuting` (the cross-workspace cron sweeper
629
+ // read) is deliberately NOT here — it stays mothership-internal.
630
+ initiativeRepository: {
631
+ get: { scope: { kind: 'workspace', arg: 0 } },
632
+ getByBlock: { scope: { kind: 'workspace', arg: 0 } },
633
+ list: { scope: { kind: 'workspace', arg: 0 } },
634
+ insert: { scope: { kind: 'workspace', arg: 0 } },
635
+ compareAndSwap: { scope: { kind: 'workspace', arg: 0 } },
636
+ delete: { scope: { kind: 'workspace', arg: 0 } },
637
+ },
638
+ consensusSessionRepository: {
639
+ get: { scope: { kind: 'workspace', arg: 0 } },
640
+ getByStep: { scope: { kind: 'workspace', arg: 0 } },
641
+ getByBlock: { scope: { kind: 'workspace', arg: 0 } },
642
+ upsert: { scope: { kind: 'workspace', arg: 0 } },
643
+ },
644
+ // --- Post-release-health / observability settings surface -----------------------
645
+ // The three settings repositories a mothership-mode SPA manages for the post-release-health
646
+ // flow: the (single) observability connection, the per-block monitor/SLO mapping, and the
647
+ // incident-enrichment connection. Their controllers mount under `/workspaces/:workspaceId`
648
+ // and are member-level (not admin-gated), so they follow the same policy as the other
649
+ // settings panels above. Reads/deletes take the workspaceId as arg0 (the `workspace` rule);
650
+ // the record-based `upsert(record)` binds on the record's `workspaceId` FIELD (the
651
+ // `workspaceField` rule — the id is a property, not a positional arg). Exposing them makes
652
+ // the observability / release-health / incident-enrichment editors functional (persist +
653
+ // read back), not read-only, in mothership mode.
654
+ //
655
+ // Scope of what this unlocks: the settings PANELS work end-to-end (save + read back the
656
+ // redacted summary, which never decrypts). The saved connection cannot yet DRIVE a
657
+ // post-release-health gate probe in mothership mode — decrypting the sealed connection cipher
658
+ // at gate-probe time belongs to the later secrets-delegation slice. The connection `get` here
659
+ // returns the FULL record (the sealed `credentials` blob), not the redacted service view: the
660
+ // RPC client is the trusted local node, the blob is sealed and account-scoped, so this matches
661
+ // the existing `environmentRegistryRepository.get` precedent (sealed cipher over the machine
662
+ // API). The record-based `upsert` binds only the top-level `record.workspaceId` (see the
663
+ // `workspaceField` note above) — `releaseHealthConfigRepository`'s `blockId` is NOT
664
+ // re-validated here, so a config can only ever be planted into the caller's own in-scope
665
+ // workspace, never another's.
666
+ observabilityConnectionRepository: {
667
+ get: { scope: { kind: 'workspace', arg: 0 } },
668
+ upsert: { scope: { kind: 'workspaceField', arg: 0 } },
669
+ delete: { scope: { kind: 'workspace', arg: 0 } },
670
+ },
671
+ releaseHealthConfigRepository: {
672
+ getByBlock: { scope: { kind: 'workspace', arg: 0 } },
673
+ listByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
674
+ upsert: { scope: { kind: 'workspaceField', arg: 0 } },
675
+ delete: { scope: { kind: 'workspace', arg: 0 } },
676
+ },
677
+ // The SENSITIVE per-service test credentials, keyed by service-frame block like
678
+ // `releaseHealthConfigRepository` above. `credentials` rides a SEALED blob (sealed/decrypted
679
+ // in the service under the LOCAL key), so no plaintext crosses the machine API — the same
680
+ // precedent as the observability / package-registry connections. The inspector CRUD
681
+ // (`getByBlock`/`deleteByBlock`) + the run-path frame read (`getByBlock`) are workspace-scoped
682
+ // on arg0; the record-based `upsert` binds on its `workspaceId` FIELD. `listByWorkspace` has no
683
+ // consumer yet, so it stays pending (marked in the allow-list completeness test).
684
+ testSecretsRepository: {
685
+ getByBlock: { scope: { kind: 'workspace', arg: 0 } },
686
+ upsert: { scope: { kind: 'workspaceField', arg: 0 } },
687
+ deleteByBlock: { scope: { kind: 'workspace', arg: 0 } },
688
+ },
689
+ // The per-service PRE-PR VALIDATION CHECKS, keyed by service-frame block like
690
+ // `releaseHealthConfigRepository` above. Nothing sealed — the commands are operator-authored
691
+ // shell strings that run inside the run's own container — so the plain record crosses the
692
+ // machine API. The inspector CRUD (`getByBlock`/`listByWorkspace`/`delete`) and the dispatch's
693
+ // frame read (`getByBlock`) are workspace-scoped on arg0; the record-based `upsert` binds on
694
+ // its `workspaceId` FIELD.
695
+ validationConfigRepository: {
696
+ getByBlock: { scope: { kind: 'workspace', arg: 0 } },
697
+ listByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
698
+ upsert: { scope: { kind: 'workspaceField', arg: 0 } },
699
+ delete: { scope: { kind: 'workspace', arg: 0 } },
700
+ },
701
+ incidentEnrichmentConnectionRepository: {
702
+ get: { scope: { kind: 'workspace', arg: 0 } },
703
+ upsert: { scope: { kind: 'workspaceField', arg: 0 } },
704
+ delete: { scope: { kind: 'workspace', arg: 0 } },
705
+ },
706
+ // The private package-registry connection (sealed npm/GitHub-Packages entries): the
707
+ // settings panel's list/add/remove and the container dispatch's decrypt-time read all
708
+ // ride get/upsert/delete, workspace-scoped like the observability connection above
709
+ // (same sealed-blob-over-the-machine-API precedent).
710
+ packageRegistryConnectionRepository: {
711
+ get: { scope: { kind: 'workspace', arg: 0 } },
712
+ upsert: { scope: { kind: 'workspaceField', arg: 0 } },
713
+ delete: { scope: { kind: 'workspace', arg: 0 } },
714
+ },
715
+ // --- VCS / GitHub projection READ surface ---------------------------------------
716
+ // The GitHub read models the SPA's VCS board panels display (repos / branches / PRs /
717
+ // issues), served straight from the local projections by `GitHubService` (`container.github`)
718
+ // — fast, rate-limit-free, and NO GitHub API call, so they run unchanged in mothership mode
719
+ // over the remote-sourced projection repos. Each takes the workspaceId as arg0 (the
720
+ // `workspace` rule); reads only.
721
+ //
722
+ // These same reads are ALSO the run path: `resolveRepoTarget` (which runs on EVERY
723
+ // container-agent dispatch to find a block's repo) reads `githubInstallationRepository.
724
+ // getByWorkspace` FIRST and returns null if there's no installation, THEN walks the
725
+ // `github_repos` projection via `repoProjectionRepository.list` and the block ancestry via
726
+ // `blockRepository.get` / `serviceRepository.getByFrameBlock` (both already remote). So
727
+ // closing the run-path gap for real (non-fake-executor) runs needs BOTH the installation
728
+ // read and `list` — allow-listing `list` alone left the resolver failing one call earlier on
729
+ // the un-remoted installation read. `getByWorkspace` is a member-level read (its own binding
730
+ // or the account-shared one), workspace-scoped on arg0.
731
+ //
732
+ // Deliberately EXCLUDED (a later "GitHub sync + repo-write" slice): the projection WRITE
733
+ // surface — `upsertMany` (the sync/webhook ingest; the mothership owns GitHub sync, since the
734
+ // App + webhooks live there), the board-linkage write `repoProjectionRepository.setMonorepo`,
735
+ // the sync cursors (`getCursor`/`setCursor`, keyed on installationId not
736
+ // workspaceId), and `tombstoneMissing`. `repoProjectionRepository.get` stays off too: it backs
737
+ // only `GitHubService.resolve` for the repo-WRITE endpoints (create-branch / open-PR /
738
+ // merge / comment), and exposing it alone would let create-branch/open-PR perform the real
739
+ // GitHub write and THEN fail on the un-remoted `upsertMany` projection refresh — a worse
740
+ // failure than today's clean pre-write refusal. It comes back with the repo-write slice. The
741
+ // rest of `githubInstallationRepository` (installationId-keyed reads, sync/token writes, the
742
+ // fan-out, the cron `listActive`) also stays off — only the workspace-scoped `getByWorkspace`
743
+ // the run path needs is opened here.
744
+ githubInstallationRepository: {
745
+ getByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
746
+ },
747
+ repoProjectionRepository: {
748
+ list: { scope: { kind: 'workspace', arg: 0 } },
749
+ },
750
+ branchProjectionRepository: {
751
+ listByRepo: { scope: { kind: 'workspace', arg: 0 } },
752
+ },
753
+ pullRequestProjectionRepository: {
754
+ listByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
755
+ },
756
+ issueProjectionRepository: {
757
+ listByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
758
+ },
759
+ // --- Self-hosted runner-backend connection surface ------------------------------
760
+ // The workspace's binding to an "agent runner backend" (the manifest HTTP pool / native
761
+ // Kubernetes runner / …) the runner-pool settings panel manages (`RunnerPoolController` →
762
+ // `RunnerPoolConnectionService`: connect / rotate secrets / disconnect / describe / test).
763
+ // The controller mounts under `/workspaces/:workspaceId` and is member-level (not admin-gated),
764
+ // so it follows the same policy as the observability / environment connection panels above.
765
+ // `getByWorkspace`/`softDelete` take the workspaceId as arg0 (the `workspace` rule); the
766
+ // record-based `upsert(record)` binds on the record's `workspaceId` FIELD (the `workspaceField`
767
+ // rule — the id is a property, not a positional arg). Exposing these makes the runner-backend
768
+ // connection panel functional (persist + read back the safe metadata) in mothership mode.
769
+ //
770
+ // Safe to expose like the observability / environment connections: the record carries the
771
+ // backend credentials as a SEALED blob (`secretsCipher`) — the repo returns it verbatim (it
772
+ // does NOT decrypt); sealing/decryption live in `RunnerPoolConnectionService` under the LOCAL
773
+ // key, so no plaintext credential crosses the machine API and the mothership only ever stores
774
+ // ciphertext (the "the mothership ENCRYPTION_KEY never reaches the laptop" split holds). The
775
+ // `workspaceField` rule binds only the record's top-level `workspaceId`, so a connection row can
776
+ // only ever land in the caller's own in-scope workspace.
777
+ runnerPoolConnectionRepository: {
778
+ getByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
779
+ upsert: { scope: { kind: 'workspaceField', arg: 0 } },
780
+ softDelete: { scope: { kind: 'workspace', arg: 0 } },
781
+ },
782
+ // --- Binary-artifact metadata surface (visual-confirmation gate) -----------------
783
+ // The metadata rows for stored binary blobs (UI screenshots + the reference design images they
784
+ // are reviewed against) the visual-confirmation gate + the artifact controllers read/write
785
+ // (`ArtifactController` / `HarnessArtifactController`, mounted under `/workspaces/:workspaceId`,
786
+ // member-level). Only the METADATA lives in the relational store (D1 ⇄ Postgres) and is proxied
787
+ // here; the BYTES live in the per-account blob backend (R2 / S3 / fs / …), resolved locally, so
788
+ // they never cross this API. Point reads/deletes take the workspaceId as arg0 (the `workspace`
789
+ // rule); the record-based `insert(record)` binds on the record's `workspaceId` FIELD (the
790
+ // `workspaceField` rule). Every read already filters by the (authenticated) workspaceId, so a
791
+ // row's non-authoritative `executionId`/`blockId` need no separate scope check. The retention
792
+ // sweep (`listOlderThan`/`deleteOlderThan`) stays mothership-internal (the mothership owns
793
+ // durable-state retention), like the other global sweeper methods.
794
+ binaryArtifactMetadataStore: {
795
+ insert: { scope: { kind: 'workspaceField', arg: 0 } },
796
+ get: { scope: { kind: 'workspace', arg: 0 } },
797
+ listByExecution: { scope: { kind: 'workspace', arg: 0 } },
798
+ countByExecution: { scope: { kind: 'workspace', arg: 0 } },
799
+ listByBlock: { scope: { kind: 'workspace', arg: 0 } },
800
+ delete: { scope: { kind: 'workspace', arg: 0 } },
801
+ },
802
+ // --- Prompt-fragment library management surface ---------------------------------
803
+ // The tenant-scoped prompt-fragment library (ADR 0006) a mothership-mode SPA curates
804
+ // (`FragmentLibraryController` → `FragmentLibraryService`): list / create / update / delete
805
+ // hand-authored fragments at either tier. The library module assembles from
806
+ // `promptFragmentRepository` ALONE (no connection/secret repo — unlike the document/task
807
+ // integrations, whose modules require a decrypt-inside connection repo and so stay off), and its
808
+ // rows carry NO secrets, so the whole management surface is remote. Every method is keyed by an
809
+ // `(ownerKind, ownerId)` PAIR (`ownerKind` ∈ `workspace` | `account`), bound by the `owner` scope
810
+ // rule (positional pair) / `ownerField` rule (the record's fields on `upsert`): a `workspace`
811
+ // owner resolves its account like the `workspace` rule, an `account` owner IS the accountId — so a
812
+ // machine token scoped to one account can never read/write another tenant's fragments. Both tiers'
813
+ // endpoints are member-level (account-tier routes guard on `requireMember`, NOT `requireAdmin`), so
814
+ // this follows the same member-level policy as the other settings/library panels above.
815
+ //
816
+ // The `sourceId`-keyed `listBySource` stays off — it is the repo-sync fan-out read (the mothership
817
+ // owns GitHub sync; the source service is gated on a GitHub client absent on a mothership node), so
818
+ // it is not on the SPA library-management path here.
819
+ promptFragmentRepository: {
820
+ listByOwner: { scope: { kind: 'owner', kindArg: 0, idArg: 1 } },
821
+ get: { scope: { kind: 'owner', kindArg: 0, idArg: 1 } },
822
+ upsert: { scope: { kind: 'ownerField', arg: 0 } },
823
+ softDelete: { scope: { kind: 'owner', kindArg: 0, idArg: 1 } },
824
+ },
825
+ // The fragment-source (repo-linkage) library the SPA lists + links (`FragmentSourceService`), owner
826
+ // scoped exactly like the fragments above. `listByOwner` (the sources list) is bound by the `owner`
827
+ // rule; the record-based `upsert(record)` by `ownerField`. The `sourceId`-keyed reads/writes
828
+ // (`get`/`updateSyncState`/`softDelete`) stay off — they back the repo-SYNC management the
829
+ // mothership owns (the source service needs a GitHub client, which a mothership node does not have),
830
+ // so a later GitHub-sync-in-mothership slice opens them with a source→owner resolver.
831
+ fragmentSourceRepository: {
832
+ listByOwner: { scope: { kind: 'owner', kindArg: 0, idArg: 1 } },
833
+ upsert: { scope: { kind: 'ownerField', arg: 0 } },
834
+ },
835
+ // --- Account onboarding read surface --------------------------------------------
836
+ // The two account-scoped READS a mothership-mode SPA's account/members + email-settings panels
837
+ // drive, both member-level (`AccountController` guards them with `requireMember`, NOT
838
+ // `requireAdmin`). arg0 is an accountId → the `account` rule (reject out-of-scope as 404). The
839
+ // account-lifecycle WRITES stay off: `invitationRepository.create`/`setStatus` (inviting/revoking
840
+ // members is admin-gated), its pre-auth `findByTokenHash`/`get` (the unauthenticated accept-invite
841
+ // lookup — never a scoped-token call), and `emailConnectionRepository.upsert`/`softDelete`
842
+ // (connect/disconnect are admin-gated). The email connection `getByAccount` returns the record with
843
+ // its provider key as a SEALED `apiKeyCipher` blob (the repo does NOT decrypt — sealing/decryption
844
+ // live in the email service; delivery is delegated to the mothership), so no plaintext credential
845
+ // crosses the machine API — the same sealed-blob precedent as the observability/runner connections.
846
+ invitationRepository: {
847
+ listByAccount: { scope: { kind: 'account', arg: 0 } },
848
+ },
849
+ emailConnectionRepository: {
850
+ getByAccount: { scope: { kind: 'account', arg: 0 } },
851
+ },
852
+ // --- Slack integration management surface ---------------------------------------
853
+ // The Slack integration settings a mothership-mode SPA manages (`SlackController` →
854
+ // `SlackConnectionService` / `SlackSettingsService` / `SlackMemberMappingService`): connect /
855
+ // disconnect the per-account Slack workspace, edit the per-workspace notification routing, and
856
+ // maintain the per-account GitHub-user → Slack-member mapping. The controller mounts under
857
+ // `/workspaces/:workspaceId` and is member-level (not admin-gated), so it follows the same policy
858
+ // as the observability / environment / runner-pool connection panels above.
859
+ //
860
+ // Safe to expose exactly like those connection surfaces: the Slack bot token rides a SEALED blob
861
+ // (`tokenCipher`) — the repo returns it verbatim (it does NOT decrypt); sealing/decryption live in
862
+ // the Slack service/channel under the LOCAL key, so no plaintext credential crosses the machine
863
+ // API and the mothership only ever stores ciphertext (the "mothership ENCRYPTION_KEY never reaches
864
+ // the laptop" split holds). The settings + member-mapping rows carry NO secrets at all.
865
+ //
866
+ // Scope of what this unlocks: the settings PANELS work end-to-end (connect / disconnect / route /
867
+ // map + read back the redacted connection view). What it does NOT change: mothership-side Slack
868
+ // DELIVERY of a notification raised by a hosted teammate — that reads + decrypts the token on the
869
+ // mothership, which cannot open a laptop-sealed blob, so it rides the later secrets-delegation
870
+ // slice, exactly like the observability gate probe. Local delivery (the run's own node raised the
871
+ // notification and holds the local key) is unaffected.
872
+ //
873
+ // `slackConnectionRepository` is per-ACCOUNT: `getByAccount`/`softDelete` take the accountId as
874
+ // arg0 (the `account` rule — the local service resolves the workspace → account via the already
875
+ // remote `workspaceRepository.accountOf`, then calls with that in-scope accountId), and the
876
+ // record-based `upsert(record)` binds on the record's `accountId` FIELD (the new `accountField`
877
+ // rule). `getByTeam` is NOT here: it is a GLOBAL teamId → connection lookup used only by the
878
+ // inbound OAuth callback / event webhook, which run on the mothership (never the laptop) and
879
+ // cannot be account-scoped — it stays mothership-internal (classified `sweeper` in the drift
880
+ // guard, the same "unscoped, mothership-internal" bucket as `repoProjectionRepository.listByInstallation`).
881
+ slackConnectionRepository: {
882
+ getByAccount: { scope: { kind: 'account', arg: 0 } },
883
+ upsert: { scope: { kind: 'accountField', arg: 0 } },
884
+ softDelete: { scope: { kind: 'account', arg: 0 } },
885
+ },
886
+ // Per-workspace notification routing (channel per notification kind + a mentions flag). No
887
+ // secrets. `getByWorkspace` takes the workspaceId as arg0 (the `workspace` rule); the
888
+ // record-based `upsert(record)` binds on the record's `workspaceId` FIELD (the `workspaceField` rule).
889
+ slackSettingsRepository: {
890
+ getByWorkspace: { scope: { kind: 'workspace', arg: 0 } },
891
+ upsert: { scope: { kind: 'workspaceField', arg: 0 } },
892
+ },
893
+ // Per-account GitHub-user → Slack-member mapping (for @-mentions). No secrets. Both methods take
894
+ // the accountId as arg0 positionally (`upsert(accountId, entries, at)` — a positional accountId,
895
+ // not a record), so the `account` rule binds both.
896
+ slackMemberMappingRepository: {
897
+ getByAccount: { scope: { kind: 'account', arg: 0 } },
898
+ upsert: { scope: { kind: 'account', arg: 0 } },
899
+ },
900
+ };
901
+ //# sourceMappingURL=rpc-allowlist.js.map