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