@growthub/cli 0.14.2 → 0.14.3

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.
Files changed (23) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/SKILL.md +4 -2
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/agent-outcomes/route.js +85 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/apps/route.js +187 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +36 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/patch/preflight/route.js +152 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +21 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +88 -1
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +72 -1
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/swarm-condition/route.js +2 -2
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +21 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflow/publish/route.js +338 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceLensPanel.jsx +1 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +22 -165
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-publish.js +179 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-activation.js +89 -5
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-app-registry.js +539 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +11 -2
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +23 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-outcome-receipts.js +157 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-patch-policy.js +400 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +10 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/skills/governed-workspace-mutation/SKILL.md +203 -0
  23. package/package.json +2 -2
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Agent Outcome Receipt V1 writer — the unified receipt every mutation lane
3
+ * emits (contract: `@growthub/api-contract/workspace-outcome`).
4
+ *
5
+ * One canonical stream answers, for any agent action:
6
+ * intent → what changed → was it preflighted → was it proven →
7
+ * was it published → what runId/sourceId/hash/version proves it →
8
+ * how does the next agent replay / rollback / continue.
9
+ *
10
+ * Storage is the EXISTING source-record sidecar (`growthub.source-records.json`)
11
+ * under the stable source id `workspace:agent-outcomes` — no new persistence
12
+ * backend. The stream is a rolling window (last 200 receipts). Existing
13
+ * helper-apply receipts and sandbox run records are untouched; outcome
14
+ * receipts LINK to them via `sourceId` / `runId` / `rollbackRef`.
15
+ *
16
+ * Safety rules enforced here, not trusted from callers:
17
+ * - every string field is secret-redacted (`redactSecrets`) and truncated;
18
+ * - receipt append failures are NEVER fatal to the mutation route
19
+ * (read-only runtimes simply do not accumulate a stream);
20
+ * - receipts carry summaries and references — never raw payloads.
21
+ */
22
+
23
+ import { createHash } from "node:crypto";
24
+ import {
25
+ readWorkspaceSourceRecords,
26
+ writeWorkspaceSourceRecords,
27
+ describePersistenceMode
28
+ } from "@/lib/workspace-config";
29
+ import { redactSecrets } from "@/lib/sandbox-agent-auth-redaction";
30
+ import { stableStringify } from "@/lib/workspace-patch-policy";
31
+
32
+ const AGENT_OUTCOMES_SOURCE_ID = "workspace:agent-outcomes";
33
+ const MAX_RECEIPTS = 200;
34
+ const MAX_SUMMARY_CHARS = 400;
35
+ const MAX_INTENT_CHARS = 280;
36
+ const MAX_LIST_ENTRIES = 24;
37
+
38
+ function newReceiptId() {
39
+ return `aor_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
40
+ }
41
+
42
+ function safeText(value, maxChars) {
43
+ const text = redactSecrets(String(value ?? "")).trim();
44
+ return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
45
+ }
46
+
47
+ function safeList(values, maxChars = 160) {
48
+ return (Array.isArray(values) ? values : [])
49
+ .slice(0, MAX_LIST_ENTRIES)
50
+ .map((v) => safeText(v, maxChars))
51
+ .filter(Boolean);
52
+ }
53
+
54
+ /**
55
+ * Build a canonical receipt from partial fields. Unknown/raw payloads are
56
+ * not accepted — only the contract's named fields survive.
57
+ */
58
+ function buildOutcomeReceipt(fields) {
59
+ const f = fields && typeof fields === "object" ? fields : {};
60
+ const receipt = {
61
+ receiptId: newReceiptId(),
62
+ kind: safeText(f.kind || "agent-outcome", 40),
63
+ lane: safeText(f.lane || "untrusted-direct", 40),
64
+ outcomeStatus: safeText(f.outcomeStatus || "failed", 24),
65
+ summary: safeText(f.summary || "", MAX_SUMMARY_CHARS) || "(no summary)",
66
+ createdAt: new Date().toISOString()
67
+ };
68
+ if (f.intent) receipt.intent = safeText(f.intent, MAX_INTENT_CHARS);
69
+ if (f.actor) receipt.actor = safeText(f.actor, 80);
70
+ if (Array.isArray(f.objectRefs)) {
71
+ receipt.objectRefs = f.objectRefs.slice(0, MAX_LIST_ENTRIES).map((ref) => {
72
+ const out = { objectId: safeText(ref?.objectId, 120) };
73
+ if (ref?.rowName) out.rowName = safeText(ref.rowName, 120);
74
+ if (ref?.objectType) out.objectType = safeText(ref.objectType, 60);
75
+ return out;
76
+ });
77
+ }
78
+ if (Array.isArray(f.changedFields)) receipt.changedFields = safeList(f.changedFields, 60);
79
+ if (f.policyVerdict && typeof f.policyVerdict === "object") {
80
+ receipt.policyVerdict = {
81
+ ok: f.policyVerdict.ok === true,
82
+ ...(Array.isArray(f.policyVerdict.violationCodes)
83
+ ? { violationCodes: safeList(f.policyVerdict.violationCodes, 60) }
84
+ : {})
85
+ };
86
+ }
87
+ if (f.schemaVerdict && typeof f.schemaVerdict === "object") {
88
+ receipt.schemaVerdict = {
89
+ ok: f.schemaVerdict.ok === true,
90
+ ...(Number.isFinite(f.schemaVerdict.errorCount) ? { errorCount: f.schemaVerdict.errorCount } : {})
91
+ };
92
+ }
93
+ for (const key of ["runId", "sourceId", "draftSha256", "publishedSha256", "version", "appId"]) {
94
+ if (f[key]) receipt[key] = safeText(f[key], 160);
95
+ }
96
+ if (Array.isArray(f.nextActions)) receipt.nextActions = safeList(f.nextActions, 240);
97
+ if (f.rollbackRef && typeof f.rollbackRef === "object") {
98
+ const rb = {};
99
+ for (const key of ["objectId", "rowName", "liveField", "previousVersion", "sourceId"]) {
100
+ if (f.rollbackRef[key]) rb[key] = safeText(f.rollbackRef[key], 120);
101
+ }
102
+ if (Number.isFinite(f.rollbackRef.deltaIndex)) rb.deltaIndex = f.rollbackRef.deltaIndex;
103
+ if (Object.keys(rb).length) receipt.rollbackRef = rb;
104
+ }
105
+ return receipt;
106
+ }
107
+
108
+ /**
109
+ * Append a receipt to the stream. NEVER throws — mutation routes must not
110
+ * fail because the receipt sidecar is read-only or momentarily unwritable.
111
+ * Returns the receipt (with `persisted` flag) so routes can echo its id.
112
+ */
113
+ async function appendOutcomeReceipt(fields) {
114
+ const receipt = buildOutcomeReceipt(fields);
115
+ try {
116
+ const persistence = describePersistenceMode();
117
+ if (!persistence.canSave) return { receipt, persisted: false };
118
+ const existing = await readWorkspaceSourceRecords(AGENT_OUTCOMES_SOURCE_ID);
119
+ const prior = Array.isArray(existing?.records) ? existing.records : [];
120
+ // Tamper-evidence (Paperclip pattern, scoped to what this runtime can
121
+ // honestly provide): server-side monotonic sequence + hash chain. Each
122
+ // receipt carries sha256(stableStringify(previous receipt)); a mutated
123
+ // or removed receipt breaks every subsequent link. No signing key /
124
+ // TEE exists in this runtime — that stronger anchor is named future work.
125
+ const last = prior.length > 0 ? prior[prior.length - 1] : null;
126
+ receipt.seq = (Number.isFinite(last?.seq) ? last.seq : prior.length - 1) + 1;
127
+ receipt.prevReceiptSha256 = last
128
+ ? createHash("sha256").update(stableStringify(last), "utf8").digest("hex")
129
+ : null;
130
+ await writeWorkspaceSourceRecords(
131
+ AGENT_OUTCOMES_SOURCE_ID,
132
+ [...prior, receipt].slice(-MAX_RECEIPTS),
133
+ { integrationId: AGENT_OUTCOMES_SOURCE_ID, fetchedAt: receipt.createdAt }
134
+ );
135
+ return { receipt, persisted: true };
136
+ } catch {
137
+ return { receipt, persisted: false };
138
+ }
139
+ }
140
+
141
+ /** Read the stream, newest first. Returns [] when absent/unreadable. */
142
+ async function readOutcomeReceipts(limit = MAX_RECEIPTS) {
143
+ try {
144
+ const existing = await readWorkspaceSourceRecords(AGENT_OUTCOMES_SOURCE_ID);
145
+ const records = Array.isArray(existing?.records) ? existing.records : [];
146
+ return records.slice(-Math.max(1, limit)).reverse();
147
+ } catch {
148
+ return [];
149
+ }
150
+ }
151
+
152
+ export {
153
+ AGENT_OUTCOMES_SOURCE_ID,
154
+ appendOutcomeReceipt,
155
+ buildOutcomeReceipt,
156
+ readOutcomeReceipts
157
+ };
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Workspace PATCH policy — server-authoritative guard for `PATCH /api/workspace`.
3
+ *
4
+ * `validateWorkspaceConfig` (workspace-schema.js) answers "is the merged
5
+ * config shaped correctly?". This module answers a different question:
6
+ * "is this *mutation* allowed to happen through direct PATCH at all?" —
7
+ * independent of whether the resulting config would validate.
8
+ *
9
+ * Enforced here, before `writeWorkspaceConfig`:
10
+ *
11
+ * 1. Allowlist — only `dashboards`, `widgetTypes`, `canvas`, `dataModel`.
12
+ * Bodies that look like a full workspace config get a dedicated reason.
13
+ * 2. `workspaceSourceRecords` never travels through PATCH (sidecar writes
14
+ * flow through POST /api/workspace/refresh-sources).
15
+ * 3. Live workflow fields on sandbox-environment rows are publish-owned.
16
+ * Direct PATCH may save drafts; only POST /api/workspace/workflow/publish
17
+ * may move a draft to the live fields, bump `version`, stamp
18
+ * `orchestrationPublishedAt`, append `orchestrationDeltas`, or set
19
+ * `lifecycleStatus: "live"`.
20
+ * 4. Size ceilings — oversized patches, oversized rows, oversized
21
+ * orchestration node configs, and history blobs smuggled into rows
22
+ * are rejected. Run history belongs in `growthub.source-records.json`.
23
+ * 5. Credential-shaped fields on sandbox rows are rejected here too, so
24
+ * `POST /api/workspace/patch/preflight` reports them without running
25
+ * full schema validation.
26
+ *
27
+ * Echo-safety: the Data Model grid and the Builder round-trip whole objects.
28
+ * A field that is byte-identical (stable JSON) to the currently persisted
29
+ * value is never a violation — only *changes* to protected fields are.
30
+ *
31
+ * Dependency-free on purpose: unit tests import this file directly
32
+ * (scripts/unit-workspace-patch-policy.test.mjs in the source repo).
33
+ */
34
+
35
+ const WORKSPACE_PATCH_ALLOWED_FIELDS = Object.freeze([
36
+ "dashboards",
37
+ "widgetTypes",
38
+ "canvas",
39
+ "dataModel"
40
+ ]);
41
+
42
+ /** Live workflow fields — only the publish route may change these. */
43
+ const LIVE_WORKFLOW_ROW_FIELDS = Object.freeze([
44
+ "orchestrationGraph",
45
+ "orchestrationConfig",
46
+ "orchestrationPublishedAt",
47
+ "orchestrationDeltas"
48
+ ]);
49
+
50
+ /** Draft workflow fields — direct PATCH may save these freely. */
51
+ const DRAFT_WORKFLOW_ROW_FIELDS = Object.freeze([
52
+ "orchestrationDraftGraph",
53
+ "orchestrationDraftConfig",
54
+ "orchestrationDraftStatus",
55
+ "orchestrationDraftUpdatedAt",
56
+ "orchestrationDraftBaseVersion",
57
+ "orchestrationDraftTestPassed",
58
+ "orchestrationDraftTestedConfig",
59
+ "orchestrationDraftLastRunId",
60
+ "orchestrationDraftLastTested",
61
+ "orchestrationDraftLastResponse"
62
+ ]);
63
+
64
+ /** Same set the schema rejects; duplicated here so preflight can report early. */
65
+ const CREDENTIAL_ROW_FIELDS = Object.freeze([
66
+ "token",
67
+ "apiKey",
68
+ "accessToken",
69
+ "refreshToken",
70
+ "bearer",
71
+ "password",
72
+ "secret",
73
+ "sessionKey"
74
+ ]);
75
+
76
+ /** Row fields whose presence as a populated array means history smuggling. */
77
+ const HISTORY_BLOB_ROW_FIELDS = Object.freeze([
78
+ "records",
79
+ "versions",
80
+ "history",
81
+ "runHistory",
82
+ "sourceRecords"
83
+ ]);
84
+
85
+ /** Top-level keys that signal "this is a whole workspace config, not a patch". */
86
+ const FULL_CONFIG_SIGNATURE_FIELDS = Object.freeze([
87
+ "id",
88
+ "name",
89
+ "description",
90
+ "branding",
91
+ "capabilities",
92
+ "pipelines",
93
+ "integrations",
94
+ "provenance"
95
+ ]);
96
+
97
+ const WORKSPACE_PATCH_LIMITS = Object.freeze({
98
+ /** Serialized PATCH body ceiling (bytes of JSON text). */
99
+ maxPatchBytes: 2_000_000,
100
+ /** Serialized single-row ceiling, unless byte-identical to the persisted row. */
101
+ maxRowBytes: 131_072,
102
+ /** Rows per dataModel object. */
103
+ maxRowsPerObject: 500,
104
+ /** Serialized single orchestration-node config ceiling. */
105
+ maxNodeConfigBytes: 65_536
106
+ });
107
+
108
+ /** Stable stringify (sorted object keys) so echo comparison is order-proof. */
109
+ function stableStringify(value) {
110
+ if (value === undefined) return "undefined";
111
+ return JSON.stringify(value, function replacer(key, v) {
112
+ if (v && typeof v === "object" && !Array.isArray(v)) {
113
+ const sorted = {};
114
+ for (const k of Object.keys(v).sort()) sorted[k] = v[k];
115
+ return sorted;
116
+ }
117
+ return v;
118
+ });
119
+ }
120
+
121
+ function sameValue(a, b) {
122
+ return stableStringify(a) === stableStringify(b);
123
+ }
124
+
125
+ function isPlainObject(value) {
126
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
127
+ }
128
+
129
+ function rowName(row) {
130
+ // Sandbox row identity is the Data Model grid's capital-N `Name` column.
131
+ return String(row?.Name ?? "").trim();
132
+ }
133
+
134
+ function violation(code, path, message) {
135
+ return { code, path, message };
136
+ }
137
+
138
+ /**
139
+ * Try to parse an orchestration graph value (string or object) far enough
140
+ * to measure node configs. Returns null when not parseable — schema
141
+ * validation owns deep correctness; the policy only measures size.
142
+ */
143
+ function parseGraphForMeasurement(value) {
144
+ if (isPlainObject(value)) return value;
145
+ if (typeof value !== "string" || !value.trim()) return null;
146
+ try {
147
+ const parsed = JSON.parse(value);
148
+ return isPlainObject(parsed) ? parsed : null;
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ function checkRowSizes(row, currentRow, path, violations) {
155
+ const serialized = stableStringify(row);
156
+ if (serialized.length > WORKSPACE_PATCH_LIMITS.maxRowBytes && !sameValue(row, currentRow)) {
157
+ violations.push(violation(
158
+ "oversized_row",
159
+ path,
160
+ `row serializes to ${serialized.length} bytes (limit ${WORKSPACE_PATCH_LIMITS.maxRowBytes}); ` +
161
+ "move bulk payloads to source records or split the row"
162
+ ));
163
+ }
164
+ for (const field of HISTORY_BLOB_ROW_FIELDS) {
165
+ const value = row[field];
166
+ if (Array.isArray(value) && value.length > 0 && !sameValue(value, currentRow?.[field])) {
167
+ violations.push(violation(
168
+ "history_smuggling",
169
+ `${path}.${field}`,
170
+ `${field} is a populated array — run/record history belongs in growthub.source-records.json ` +
171
+ "(written by sandbox-run / refresh-sources), never inside dataModel rows"
172
+ ));
173
+ }
174
+ }
175
+ for (const graphField of ["orchestrationGraph", "orchestrationConfig", "orchestrationDraftGraph", "orchestrationDraftConfig"]) {
176
+ if (sameValue(row[graphField], currentRow?.[graphField])) continue;
177
+ const graph = parseGraphForMeasurement(row[graphField]);
178
+ if (!graph || !Array.isArray(graph.nodes)) continue;
179
+ graph.nodes.forEach((node, nodeIndex) => {
180
+ const config = node?.config;
181
+ if (!isPlainObject(config)) return;
182
+ const size = stableStringify(config).length;
183
+ if (size > WORKSPACE_PATCH_LIMITS.maxNodeConfigBytes) {
184
+ violations.push(violation(
185
+ "oversized_node_config",
186
+ `${path}.${graphField}.nodes[${nodeIndex}].config`,
187
+ `node config serializes to ${size} bytes (limit ${WORKSPACE_PATCH_LIMITS.maxNodeConfigBytes}); ` +
188
+ "reference large payloads through source records or env refs instead of inlining them"
189
+ ));
190
+ }
191
+ });
192
+ }
193
+ }
194
+
195
+ function checkSandboxRow(row, currentRow, path, violations) {
196
+ for (const field of CREDENTIAL_ROW_FIELDS) {
197
+ if (row[field] !== undefined) {
198
+ violations.push(violation(
199
+ "credential_field",
200
+ `${path}.${field}`,
201
+ `${field} is not allowed on a sandbox row — auth secrets must stay in the local CLI's own store; ` +
202
+ "rows carry authRef / env-ref names only"
203
+ ));
204
+ }
205
+ }
206
+
207
+ const isNewRow = !currentRow;
208
+ for (const field of LIVE_WORKFLOW_ROW_FIELDS) {
209
+ const incoming = row[field];
210
+ if (isNewRow) {
211
+ const populated = Array.isArray(incoming)
212
+ ? incoming.length > 0
213
+ : incoming !== undefined && incoming !== null && String(incoming).trim() !== "";
214
+ if (populated) {
215
+ violations.push(violation(
216
+ "live_workflow_field",
217
+ `${path}.${field}`,
218
+ `${field} may not be created through direct PATCH — save it as a draft ` +
219
+ "(orchestrationDraft*) and promote it through POST /api/workspace/workflow/publish"
220
+ ));
221
+ }
222
+ continue;
223
+ }
224
+ if (!sameValue(incoming, currentRow[field])) {
225
+ violations.push(violation(
226
+ "live_workflow_field",
227
+ `${path}.${field}`,
228
+ `${field} is publish-owned — direct PATCH may only echo the persisted value; ` +
229
+ "use POST /api/workspace/workflow/publish to change the live workflow"
230
+ ));
231
+ }
232
+ }
233
+
234
+ if (!isNewRow && row.version !== undefined && !sameValue(row.version, currentRow.version)) {
235
+ violations.push(violation(
236
+ "live_workflow_field",
237
+ `${path}.version`,
238
+ "version increments are publish-owned — direct PATCH may only echo the persisted version"
239
+ ));
240
+ }
241
+
242
+ const incomingStatus = String(row.lifecycleStatus ?? "").trim().toLowerCase();
243
+ const currentStatus = String(currentRow?.lifecycleStatus ?? "").trim().toLowerCase();
244
+ if (incomingStatus === "live" && currentStatus !== "live") {
245
+ violations.push(violation(
246
+ "live_publish_via_patch",
247
+ `${path}.lifecycleStatus`,
248
+ 'lifecycleStatus: "live" is publish-owned — POST /api/workspace/workflow/publish is the only ' +
249
+ "transition into live; direct PATCH may keep a live row live or move it back to draft"
250
+ ));
251
+ }
252
+ }
253
+
254
+ function checkDataModel(dataModel, currentConfig, violations) {
255
+ if (dataModel === undefined) return;
256
+ if (!isPlainObject(dataModel) || (dataModel.objects !== undefined && !Array.isArray(dataModel.objects))) {
257
+ // Shape errors are the validator's domain; the policy stops here so the
258
+ // two layers never disagree about the same malformed input.
259
+ return;
260
+ }
261
+ const currentObjects = Array.isArray(currentConfig?.dataModel?.objects)
262
+ ? currentConfig.dataModel.objects
263
+ : [];
264
+ const currentById = new Map(currentObjects.map((o) => [String(o?.id ?? ""), o]));
265
+
266
+ (dataModel.objects ?? []).forEach((object, objectIndex) => {
267
+ if (!isPlainObject(object)) return;
268
+ const path = `dataModel.objects[${objectIndex}]`;
269
+ const currentObject = currentById.get(String(object.id ?? "")) ?? null;
270
+ const rows = Array.isArray(object.rows) ? object.rows : [];
271
+
272
+ if (rows.length > WORKSPACE_PATCH_LIMITS.maxRowsPerObject) {
273
+ violations.push(violation(
274
+ "oversized_object",
275
+ `${path}.rows`,
276
+ `${rows.length} rows exceeds the ${WORKSPACE_PATCH_LIMITS.maxRowsPerObject}-row ceiling per object; ` +
277
+ "page bulk data through source records instead"
278
+ ));
279
+ }
280
+
281
+ const objectType = String(object.objectType ?? currentObject?.objectType ?? "").trim();
282
+ const currentRows = Array.isArray(currentObject?.rows) ? currentObject.rows : [];
283
+ const currentRowsByName = new Map(
284
+ currentRows.filter((r) => rowName(r)).map((r) => [rowName(r), r])
285
+ );
286
+
287
+ rows.forEach((row, rowIndex) => {
288
+ if (!isPlainObject(row)) return;
289
+ const rowPath = `${path}.rows[${rowIndex}]`;
290
+ const currentRow = rowName(row) ? currentRowsByName.get(rowName(row)) ?? null : null;
291
+ checkRowSizes(row, currentRow, rowPath, violations);
292
+ if (objectType === "sandbox-environment") {
293
+ checkSandboxRow(row, currentRow, rowPath, violations);
294
+ }
295
+ });
296
+ });
297
+ }
298
+
299
+ /**
300
+ * Evaluate a PATCH body against the mutation policy.
301
+ *
302
+ * @param {object|null} currentConfig — currently persisted workspace config.
303
+ * @param {unknown} patch — the incoming PATCH body, exactly as received.
304
+ * @returns {{ ok: boolean, violations: Array<{code: string, path: string, message: string}> }}
305
+ */
306
+ function evaluateWorkspacePatchPolicy(currentConfig, patch) {
307
+ const violations = [];
308
+
309
+ if (!isPlainObject(patch)) {
310
+ violations.push(violation("invalid_body", "", "patch must be a plain object"));
311
+ return { ok: false, violations };
312
+ }
313
+
314
+ const unknown = Object.keys(patch).filter((key) => !WORKSPACE_PATCH_ALLOWED_FIELDS.includes(key));
315
+ if (unknown.includes("workspaceSourceRecords")) {
316
+ violations.push(violation(
317
+ "source_records_through_patch",
318
+ "workspaceSourceRecords",
319
+ "workspaceSourceRecords is GET-only hydration — sidecar writes flow through " +
320
+ "POST /api/workspace/refresh-sources, never through PATCH"
321
+ ));
322
+ }
323
+ const signatureHits = unknown.filter((key) => FULL_CONFIG_SIGNATURE_FIELDS.includes(key));
324
+ if (signatureHits.length >= 2) {
325
+ violations.push(violation(
326
+ "full_config_body",
327
+ "",
328
+ `body carries whole-config fields (${signatureHits.join(", ")}) — never PATCH the full ` +
329
+ "workspace config back; send only the changed allowlisted key(s)"
330
+ ));
331
+ }
332
+ for (const key of unknown) {
333
+ if (key === "workspaceSourceRecords") continue;
334
+ violations.push(violation(
335
+ "unknown_field",
336
+ key,
337
+ `${key} is outside the permanent PATCH allowlist (${WORKSPACE_PATCH_ALLOWED_FIELDS.join(", ")})`
338
+ ));
339
+ }
340
+
341
+ const serialized = stableStringify(patch);
342
+ if (serialized.length > WORKSPACE_PATCH_LIMITS.maxPatchBytes) {
343
+ violations.push(violation(
344
+ "oversized_patch",
345
+ "",
346
+ `patch serializes to ${serialized.length} bytes (limit ${WORKSPACE_PATCH_LIMITS.maxPatchBytes})`
347
+ ));
348
+ }
349
+
350
+ checkDataModel(patch.dataModel, currentConfig, violations);
351
+
352
+ return { ok: violations.length === 0, violations };
353
+ }
354
+
355
+ /**
356
+ * Repair guidance — one governed alternative per violation code, so a
357
+ * rejection teaches self-correction instead of provoking retry loops.
358
+ * Returned by patch/preflight (`repairPlan[]`) and the PATCH 422 envelope.
359
+ */
360
+ const REPAIR_PLANS = Object.freeze({
361
+ invalid_body: "Send a single JSON object containing only changed allowlisted keys.",
362
+ unknown_field: `Remove keys outside the allowlist (${WORKSPACE_PATCH_ALLOWED_FIELDS.join(", ")}); other config fields are read-only through this API.`,
363
+ full_config_body: "Never PATCH the whole workspace config back — GET first, then send only the changed allowlisted key(s).",
364
+ source_records_through_patch: "Write source records through POST /api/workspace/refresh-sources (or let sandbox-run persist run records); PATCH never touches the sidecar.",
365
+ oversized_patch: "Split the change into smaller PATCHes per allowlisted key, and move bulk data into source records.",
366
+ oversized_object: "Page bulk rows through source records; keep dataModel objects under the row ceiling.",
367
+ oversized_row: "Move bulk payloads into source records and store only a sourceId/reference on the row.",
368
+ oversized_node_config: "Reference large payloads from node configs via source records or env refs instead of inlining them.",
369
+ history_smuggling: "Run/record history lives in growthub.source-records.json (written by sandbox-run / refresh-sources) — store only lastRunId/lastSourceId on the row.",
370
+ credential_field: "Store the secret in the local CLI's own store and reference it via authRef / envRefs names only.",
371
+ live_workflow_field: "Move the graph into orchestrationDraftConfig (or orchestrationDraftGraph), prove it with POST /api/workspace/sandbox-run {useDraft:true}, then promote it with POST /api/workspace/workflow/publish.",
372
+ live_publish_via_patch: "lifecycleStatus \"live\" is set only by POST /api/workspace/workflow/publish after a verified draft test."
373
+ });
374
+
375
+ /** Ordered, deduplicated repair steps for a violation list. */
376
+ function repairPlanForViolations(violations) {
377
+ const seen = new Set();
378
+ const plan = [];
379
+ for (const v of Array.isArray(violations) ? violations : []) {
380
+ const step = REPAIR_PLANS[v?.code];
381
+ if (step && !seen.has(step)) {
382
+ seen.add(step);
383
+ plan.push(step);
384
+ }
385
+ }
386
+ return plan;
387
+ }
388
+
389
+ export {
390
+ CREDENTIAL_ROW_FIELDS,
391
+ DRAFT_WORKFLOW_ROW_FIELDS,
392
+ FULL_CONFIG_SIGNATURE_FIELDS,
393
+ HISTORY_BLOB_ROW_FIELDS,
394
+ LIVE_WORKFLOW_ROW_FIELDS,
395
+ WORKSPACE_PATCH_ALLOWED_FIELDS,
396
+ WORKSPACE_PATCH_LIMITS,
397
+ evaluateWorkspacePatchPolicy,
398
+ repairPlanForViolations,
399
+ stableStringify
400
+ };
@@ -54,6 +54,7 @@
54
54
  "helpers/upload-graded-traces.mjs",
55
55
  "helpers/export-training-traces.mjs",
56
56
  "skills/README.md",
57
+ "skills/governed-workspace-mutation/SKILL.md",
57
58
  "examples/workspace-sample.md",
58
59
  "docs/starter-kit-overview.md",
59
60
  "docs/fork-sync-integration.md",
@@ -90,6 +91,14 @@
90
91
  "apps/workspace/lib/workspace-activation.js",
91
92
  "apps/workspace/app/settings/integrations/page.jsx",
92
93
  "apps/workspace/app/api/workspace/route.js",
94
+ "apps/workspace/app/api/workspace/patch/preflight/route.js",
95
+ "apps/workspace/app/api/workspace/workflow/publish/route.js",
96
+ "apps/workspace/lib/workspace-patch-policy.js",
97
+ "apps/workspace/lib/orchestration-publish.js",
98
+ "apps/workspace/lib/workspace-outcome-receipts.js",
99
+ "apps/workspace/app/api/workspace/agent-outcomes/route.js",
100
+ "apps/workspace/lib/workspace-app-registry.js",
101
+ "apps/workspace/app/api/workspace/apps/route.js",
93
102
  "apps/workspace/app/api/workspace/refresh-sources/route.js",
94
103
  "apps/workspace/app/api/workspace/test-source/route.js",
95
104
  "apps/workspace/app/api/workspace/register-resolver/route.js",
@@ -190,6 +199,7 @@
190
199
  "helpers/export-training-traces.mjs",
191
200
  "skills",
192
201
  "skills/README.md",
202
+ "skills/governed-workspace-mutation/SKILL.md",
193
203
  "docs",
194
204
  "docs/adapter-contracts.md",
195
205
  "docs/vercel-serverless-deployment.md",