@growthub/cli 0.14.9 → 0.14.10

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.
@@ -35,6 +35,11 @@ import { readWorkspaceConfig, readWorkspaceSourceRecords } from "@/lib/workspace
35
35
  import { buildWorkspaceMetadataStore } from "@/lib/workspace-metadata-store";
36
36
  import { buildWorkspaceMetadataGraph } from "@/lib/workspace-metadata-graph";
37
37
  import { selectStaleMetadataGroups } from "@/lib/workspace-metadata-selectors";
38
+ import { deriveBlastRadius } from "@/lib/workspace-metadata-impact";
39
+ import { deriveStaleSurfaces } from "@/lib/workspace-stale-surfaces";
40
+ import { deriveWorkflowImpact } from "@/lib/workspace-workflow-impact";
41
+ import { deriveProvenanceLineage } from "@/lib/workspace-provenance-lineage";
42
+ import { deriveAppReadiness } from "@/lib/workspace-app-readiness";
38
43
 
39
44
  const ENVELOPE_KIND = "growthub-workspace-metadata-graph-v1";
40
45
  const ENVELOPE_VERSION = 1;
@@ -111,10 +116,17 @@ async function GET(request) {
111
116
  // Optional stale-group selector via query params.
112
117
  let staleGroups = [];
113
118
  let staleReasons = [];
119
+ // Parse all causal query params from one URL read.
120
+ let impactId = "";
121
+ let lineageId = "";
122
+ let lineageDirection = "both";
114
123
  try {
115
124
  const url = request && request.url ? new URL(request.url) : null;
116
125
  const staleKind = url ? (url.searchParams.get("staleKind") || "").trim() : "";
117
126
  const staleId = url ? (url.searchParams.get("staleId") || "").trim() : "";
127
+ impactId = url ? (url.searchParams.get("impactId") || "").trim() : "";
128
+ lineageId = url ? (url.searchParams.get("lineageId") || "").trim() : "";
129
+ lineageDirection = url ? (url.searchParams.get("lineageDirection") || "both").trim() : "both";
118
130
  if (staleKind && staleId) {
119
131
  const result = selectStaleMetadataGroups(metadataStore, { kind: staleKind, id: staleId });
120
132
  staleGroups = Array.isArray(result?.groups) ? result.groups : [];
@@ -124,6 +136,30 @@ async function GET(request) {
124
136
  warnings.push(`Failed to compute stale groups: ${error?.message || "unknown error"}`);
125
137
  }
126
138
 
139
+ // Causal derivations over the same read-only graph (Mutation → Law →
140
+ // Intelligence). All pure, all bounded, all secret-free. `staleSurfaces` is
141
+ // the unconditional freshness baseline (timestamps already in the graph);
142
+ // `impact` and `lineage` are computed on demand for one node.
143
+ let staleSurfaces = null;
144
+ let readiness = null;
145
+ let impact = null;
146
+ let lineage = null;
147
+ try {
148
+ staleSurfaces = deriveStaleSurfaces(graph);
149
+ readiness = deriveAppReadiness(graph);
150
+ if (impactId) {
151
+ impact = {
152
+ blastRadius: deriveBlastRadius(graph, impactId),
153
+ workflowImpact: deriveWorkflowImpact(graph, impactId)
154
+ };
155
+ }
156
+ if (lineageId) {
157
+ lineage = deriveProvenanceLineage(graph, lineageId, { direction: lineageDirection });
158
+ }
159
+ } catch (error) {
160
+ warnings.push(`Failed to compute causal derivations: ${error?.message || "unknown error"}`);
161
+ }
162
+
127
163
  return NextResponse.json({
128
164
  kind: ENVELOPE_KIND,
129
165
  version: ENVELOPE_VERSION,
@@ -163,6 +199,11 @@ async function GET(request) {
163
199
  groups: staleGroups,
164
200
  reasons: staleReasons
165
201
  },
202
+ // Causal intelligence layer — read-only derivations over `graph` above.
203
+ staleSurfaces,
204
+ readiness,
205
+ ...(impact ? { impact } : {}),
206
+ ...(lineage ? { lineage } : {}),
166
207
  warnings,
167
208
  selectors: {
168
209
  // Manifest of selectors the route honours. Only `selectStaleMetadataGroups`
@@ -170,7 +211,14 @@ async function GET(request) {
170
211
  // selectors are exposed as importable helpers for server-side consumers
171
212
  // and the read-only inspector; they are NOT toggled through query
172
213
  // params in V1.
173
- httpEnabled: ["selectStaleMetadataGroups"],
214
+ httpEnabled: [
215
+ "selectStaleMetadataGroups",
216
+ "deriveStaleSurfaces",
217
+ "deriveBlastRadius",
218
+ "deriveWorkflowImpact",
219
+ "deriveProvenanceLineage",
220
+ "deriveAppReadiness"
221
+ ],
174
222
  helperOnly: [
175
223
  "selectWidgetRequiredFields",
176
224
  "selectWorkflowNodeInputSchema",
@@ -35,6 +35,38 @@ import {
35
35
  } from "@/lib/workspace-patch-policy";
36
36
  import { evaluateAppScope, requireAppScope } from "@/lib/workspace-app-registry";
37
37
  import { appendOutcomeReceipt } from "@/lib/workspace-outcome-receipts";
38
+ import { readWorkspaceSourceRecords } from "@/lib/workspace-config";
39
+ import { buildWorkspaceMetadataStore } from "@/lib/workspace-metadata-store";
40
+ import { buildWorkspaceMetadataGraph } from "@/lib/workspace-metadata-graph";
41
+ import { derivePatchImpact } from "@/lib/workspace-patch-impact";
42
+
43
+ /**
44
+ * Report the blast radius of a proposed patch BEFORE the write — the S1
45
+ * spine's intended preflight consumption. Builds the metadata graph from the
46
+ * MERGED config (what the workspace becomes if this patch lands), maps the
47
+ * patched dataModel objects / dashboards to their graph node ids, and runs the
48
+ * pure `deriveStaleSurfaces` seed path over them. Pure, additive, never
49
+ * throws — on any failure the preflight verdict is unaffected and `impact`
50
+ * is simply omitted.
51
+ */
52
+ async function computePatchImpact(currentConfig, mergedConfig, patch) {
53
+ try {
54
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) return null;
55
+ let sourceRecords = {};
56
+ try { sourceRecords = (await readWorkspaceSourceRecords()) || {}; } catch { sourceRecords = {}; }
57
+ // Build BOTH graphs so the impact deriver can report not just added/modified
58
+ // (on the merged graph) but also REMOVED objects/dashboards (whose downstream
59
+ // lived in the current graph). One shared, unit-tested deriver does the diff.
60
+ const buildGraph = (cfg) => buildWorkspaceMetadataGraph(buildWorkspaceMetadataStore({ workspaceConfig: cfg || {}, workspaceSourceRecords: sourceRecords }));
61
+ const currentGraph = buildGraph(currentConfig);
62
+ const mergedGraph = buildGraph(mergedConfig);
63
+ const impact = derivePatchImpact(currentGraph, mergedGraph, currentConfig || {}, mergedConfig || {});
64
+ if (!impact.total && !impact.removed.length) return null;
65
+ return impact;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
38
70
 
39
71
  async function POST(request) {
40
72
  let patch;
@@ -66,12 +98,14 @@ async function POST(request) {
66
98
  // PATCH about the merged result. Skipped when the body is not a plain
67
99
  // object (policy already reports that).
68
100
  let schema = { ok: true, errors: [] };
101
+ let mergedConfig = null;
69
102
  if (patch && typeof patch === "object" && !Array.isArray(patch)) {
70
103
  const sanitized = {};
71
104
  for (const key of WORKSPACE_PATCH_ALLOWED_FIELDS) {
72
105
  if (Object.prototype.hasOwnProperty.call(patch, key)) sanitized[key] = patch[key];
73
106
  }
74
107
  const merged = applyWorkspaceConfigPatch(currentConfig || {}, sanitized);
108
+ mergedConfig = merged;
75
109
  try {
76
110
  validateWorkspaceConfig({
77
111
  dashboards: merged.dashboards,
@@ -103,6 +137,9 @@ async function POST(request) {
103
137
  }
104
138
  }
105
139
 
140
+ // Blast radius of this patch BEFORE the write (additive; never blocks).
141
+ const impact = await computePatchImpact(currentConfig, mergedConfig, patch);
142
+
106
143
  const persistence = describePersistenceMode();
107
144
  const ok = policy.ok && schema.ok && (appScopeVerdict ? appScopeVerdict.allowed : true);
108
145
  const repairPlan = repairPlanForViolations(policy.violations);
@@ -140,6 +177,7 @@ async function POST(request) {
140
177
  schema,
141
178
  repairPlan,
142
179
  ...(appScopeVerdict ? { appScopeVerdict } : {}),
180
+ ...(impact ? { impact } : {}),
143
181
  ...(safeNextStep ? { safeNextStep } : {}),
144
182
  persistence: {
145
183
  mode: persistence.mode,
@@ -62,6 +62,27 @@ function buildAuthHeaders(record, secretValue) {
62
62
  return { [headerName]: prefix ? `${prefix} ${secretValue}` : secretValue };
63
63
  }
64
64
 
65
+ function executeFeatureSeedMock(url, { registryId, method, startedAt }) {
66
+ if (!String(url || "").startsWith("mock://growthub-feature-seed/")) return null;
67
+ return {
68
+ ok: true,
69
+ exitCode: 0,
70
+ durationMs: Date.now() - startedAt,
71
+ stdout: JSON.stringify({ ok: true, status: 200, data: [{ id: "rec-1", label: "Probe record", registryId }] }, null, 2),
72
+ stderr: "",
73
+ rawPayload: { ok: true, status: 200, data: [{ id: "rec-1", label: "Probe record", registryId }] },
74
+ httpStatus: 200,
75
+ adapterMeta: {
76
+ mode: "orchestration-graph",
77
+ registryId,
78
+ url,
79
+ httpStatus: 200,
80
+ method,
81
+ transport: "feature-seed-mock"
82
+ }
83
+ };
84
+ }
85
+
65
86
  function findRegistryRecord(workspaceConfig, registryId) {
66
87
  const id = String(registryId || "").trim();
67
88
  if (!id) return null;
@@ -203,6 +224,12 @@ async function executeApiRegistryCall(workspaceConfig, nodeConfig, inputPayload,
203
224
  }
204
225
  }
205
226
 
227
+ const mockResult = executeFeatureSeedMock(url, { registryId, method, startedAt });
228
+ if (mockResult) {
229
+ clearTimeout(timer);
230
+ return mockResult;
231
+ }
232
+
206
233
  try {
207
234
  const response = await fetch(url, {
208
235
  method,
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Growthub Workspace App-Readiness V1 — ship-readiness deriver.
3
+ *
4
+ * Composes the signals the workspace already records into ONE app-scoped
5
+ * eligibility verdict: `{ ready, blocking[], nextAction }`. Eligibility, not a
6
+ * flag — readiness is computed from the live state every call, never stored.
7
+ *
8
+ * Signal sources, all already present in the read-only metadata graph the
9
+ * Workspace Map builds:
10
+ * - integration.status — an unconnected integration blocks its dependents
11
+ * - sandbox.authStatus — an unauthenticated sandbox cannot run
12
+ * - pipelineHealth.status / latestOk — an untested or failing pipeline blocks
13
+ * - workflow.lifecycleStatus — a draft-only workflow is a soft (non-blocking)
14
+ * signal that proof is still pending
15
+ *
16
+ * Callers that hold richer signals (env-status, deploy check shape, swarm
17
+ * eligibility) may pass them via `options.extraBlockers` / `options.extraSignals`
18
+ * — they are merged in without changing the shape. Pure: no fetch, no fs, no
19
+ * writes, no secrets. Deterministic ordering so receipts/diffs stay clean.
20
+ */
21
+
22
+ const APP_READINESS_KIND = "growthub-workspace-app-readiness-v1";
23
+ const APP_READINESS_VERSION = 1;
24
+
25
+ // Severity rank → lower sorts first (most urgent blocker becomes nextAction).
26
+ const SEVERITY = { blocker: 0, warning: 1 };
27
+
28
+ function safeString(value) {
29
+ if (value == null) return "";
30
+ return typeof value === "string" ? value : String(value);
31
+ }
32
+
33
+ function isConnected(status) {
34
+ const s = safeString(status).toLowerCase();
35
+ return s === "connected" || s === "ok" || s === "active" || s === "ready";
36
+ }
37
+
38
+ // Three-state auth: "authed" | "unauthed" | "unknown". Empty is UNKNOWN, never
39
+ // silently "authed" — unknown auth must not pass as ready (review finding D).
40
+ function authState(status) {
41
+ const s = safeString(status).toLowerCase();
42
+ if (["authed", "authenticated", "ok", "connected", "ready"].includes(s)) return "authed";
43
+ if (!s) return "unknown";
44
+ return "unauthed";
45
+ }
46
+
47
+ // A local / no-auth sandbox legitimately has no credential — an intentional
48
+ // exception, not a silent default.
49
+ function isNoAuthSandbox(summary) {
50
+ const locality = safeString(summary.runLocality).toLowerCase();
51
+ const adapter = safeString(summary.adapter).toLowerCase();
52
+ const provider = safeString(summary.authProvider).toLowerCase();
53
+ return locality === "local" || adapter.includes("local") || provider === "none" || provider === "local";
54
+ }
55
+
56
+ /**
57
+ * @param {object} graph a `buildWorkspaceMetadataGraph` envelope
58
+ * @param {object} [options]
59
+ * @param {string} [options.appId] restrict to nodes scoped to this app (when
60
+ * node summaries carry `appId`/`appScope`); omit for workspace scope.
61
+ * @param {Array<{severity?:string,code:string,message:string,nextAction?:string}>} [options.extraBlockers]
62
+ * @param {object} [options.extraSignals] merged verbatim into `signals`.
63
+ * @returns {object} `{ kind, version, appId, ready, score, blocking[], warnings[], signals, nextAction, summary }`
64
+ */
65
+ function deriveAppReadiness(graph, options = {}) {
66
+ const appId = safeString(options.appId).trim() || null;
67
+
68
+ const empty = (warning) => ({
69
+ kind: APP_READINESS_KIND,
70
+ version: APP_READINESS_VERSION,
71
+ appId,
72
+ ready: false,
73
+ score: 0,
74
+ blocking: [],
75
+ warnings: warning ? [warning] : [],
76
+ signals: {},
77
+ nextAction: null,
78
+ summary: "No readiness computed."
79
+ });
80
+
81
+ if (!graph || typeof graph !== "object" || !Array.isArray(graph.nodes)) {
82
+ return empty("graph missing or malformed");
83
+ }
84
+
85
+ const inScope = (node) => {
86
+ if (!appId) return true;
87
+ const scope = node.summary && (node.summary.appId || node.summary.appScope);
88
+ return safeString(scope) === appId;
89
+ };
90
+
91
+ const nodes = graph.nodes.filter(inScope);
92
+ const issues = [];
93
+ const counts = { integrations: 0, sandboxes: 0, pipelines: 0, workflows: 0 };
94
+
95
+ for (const node of nodes) {
96
+ const s = node.summary || {};
97
+ if (node.type === "integration") {
98
+ counts.integrations += 1;
99
+ if (!isConnected(s.status)) {
100
+ issues.push({
101
+ severity: "blocker",
102
+ code: "integration_not_connected",
103
+ subject: node.label || node.id,
104
+ message: `Integration "${node.label || node.id}" is ${safeString(s.status) || "not connected"}.`,
105
+ nextAction: `Connect integration "${node.label || node.id}" (test-source / auth), then re-check readiness.`
106
+ });
107
+ }
108
+ } else if (node.type === "sandbox") {
109
+ counts.sandboxes += 1;
110
+ const state = authState(s.authStatus);
111
+ const subject = node.label || node.id;
112
+ if (state === "authed" || isNoAuthSandbox(s)) {
113
+ // authed, or a deliberate local/no-auth sandbox — ready.
114
+ } else if (state === "unknown") {
115
+ issues.push({
116
+ severity: "warning",
117
+ code: "sandbox_auth_unknown",
118
+ subject,
119
+ message: `Sandbox "${subject}" has no auth status — cannot confirm it can run, and it is not marked local/no-auth.`,
120
+ nextAction: `Authenticate sandbox "${subject}", or mark it local/no-auth (runLocality: local) if it needs no credential.`
121
+ });
122
+ } else {
123
+ issues.push({
124
+ severity: "blocker",
125
+ code: "sandbox_unauthenticated",
126
+ subject,
127
+ message: `Sandbox "${subject}" auth status is ${safeString(s.authStatus)}.`,
128
+ nextAction: `Authenticate sandbox "${subject}" before running.`
129
+ });
130
+ }
131
+ } else if (node.type === "pipelineHealth") {
132
+ counts.pipelines += 1;
133
+ const status = safeString(s.status).toLowerCase();
134
+ if (status === "untested" || s.latestOk === false) {
135
+ issues.push({
136
+ severity: status === "untested" ? "warning" : "blocker",
137
+ code: status === "untested" ? "pipeline_untested" : "pipeline_failing",
138
+ subject: node.label || node.id,
139
+ message: `Pipeline "${node.label || node.id}" is ${status || "failing"}.`,
140
+ nextAction: status === "untested"
141
+ ? `Run "${node.label || node.id}" once to prove it (POST /api/workspace/sandbox-run).`
142
+ : `Investigate the last failing run of "${node.label || node.id}".`
143
+ });
144
+ }
145
+ } else if (node.type === "workflow") {
146
+ counts.workflows += 1;
147
+ if (safeString(s.lifecycleStatus).toLowerCase() === "draft") {
148
+ issues.push({
149
+ severity: "warning",
150
+ code: "workflow_draft_only",
151
+ subject: node.label || node.id,
152
+ message: `Workflow "${node.label || node.id}" is draft-only — durable proof pending.`,
153
+ nextAction: `Prove "${node.label || node.id}" with a sandbox run, then publish.`
154
+ });
155
+ }
156
+ }
157
+ }
158
+
159
+ for (const extra of Array.isArray(options.extraBlockers) ? options.extraBlockers : []) {
160
+ if (!extra || !extra.code) continue;
161
+ issues.push({
162
+ severity: extra.severity === "warning" ? "warning" : "blocker",
163
+ code: safeString(extra.code),
164
+ subject: safeString(extra.subject) || safeString(extra.code),
165
+ message: safeString(extra.message) || safeString(extra.code),
166
+ nextAction: extra.nextAction ? safeString(extra.nextAction) : null
167
+ });
168
+ }
169
+
170
+ issues.sort((a, b) =>
171
+ (SEVERITY[a.severity] ?? 9) - (SEVERITY[b.severity] ?? 9) ||
172
+ a.code.localeCompare(b.code) ||
173
+ a.subject.localeCompare(b.subject)
174
+ );
175
+
176
+ const blocking = issues.filter((i) => i.severity === "blocker");
177
+ const warnings = issues.filter((i) => i.severity === "warning");
178
+ const ready = blocking.length === 0;
179
+
180
+ // Score: 100 when ready and clean; each blocker −25, each warning −5, floored at 0.
181
+ const score = Math.max(0, 100 - blocking.length * 25 - warnings.length * 5);
182
+ const nextAction = (blocking[0]?.nextAction)
183
+ || (warnings[0]?.nextAction)
184
+ || (ready ? "Ready — promote/deploy through the governed lane." : null);
185
+
186
+ return {
187
+ kind: APP_READINESS_KIND,
188
+ version: APP_READINESS_VERSION,
189
+ appId,
190
+ ready,
191
+ score,
192
+ blocking,
193
+ warnings,
194
+ signals: { ...counts, ...(options.extraSignals && typeof options.extraSignals === "object" ? options.extraSignals : {}) },
195
+ nextAction,
196
+ summary: summarizeReadiness(appId, ready, blocking, warnings, score)
197
+ };
198
+ }
199
+
200
+ function summarizeReadiness(appId, ready, blocking, warnings, score) {
201
+ const scope = appId ? `App "${appId}"` : "Workspace";
202
+ if (ready && !warnings.length) return `${scope} is ready to ship (score ${score}).`;
203
+ if (ready) return `${scope} is ready (score ${score}) with ${warnings.length} warning(s).`;
204
+ return `${scope} is blocked (score ${score}): ${blocking.length} blocker(s), ${warnings.length} warning(s).`;
205
+ }
206
+
207
+ export {
208
+ APP_READINESS_KIND,
209
+ APP_READINESS_VERSION,
210
+ deriveAppReadiness,
211
+ summarizeReadiness
212
+ };
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Growthub Workspace Contract-Compliance V1 — governed-mutation predicate.
3
+ *
4
+ * Given a PROPOSED mutation and a role/SKILL contract, derive the set of proofs,
5
+ * ledgers, and review states that must be satisfied for the mutation to be
6
+ * legal — and which of those are already satisfied by the supplied evidence.
7
+ *
8
+ * This is a PURE PREDICATE over law artifacts the caller already holds (the
9
+ * contract rules + the evidence gathered from receipts/review state). It reads
10
+ * NO state itself, writes nothing, and exposes no secrets. It does not widen
11
+ * any mutation boundary — it only reports what the existing governed lanes
12
+ * require, the same rules the `governed-workspace-mutation` SKILL encodes:
13
+ * - live workflow fields are never PATCHed directly — they need a draft, a
14
+ * proving sandbox-run, then publish;
15
+ * - dataModel / dashboard mutations need a preflight receipt;
16
+ * - anything outside the PATCH allowlist is rejected by construction.
17
+ *
18
+ * Default contract (when none supplied) mirrors the shipped boundary so the
19
+ * predicate is useful out of the box; callers pass a stricter contract per role.
20
+ */
21
+
22
+ const CONTRACT_COMPLIANCE_KIND = "growthub-workspace-contract-compliance-v1";
23
+ const CONTRACT_COMPLIANCE_VERSION = 1;
24
+
25
+ // The permanent PATCH allowlist — the floor every contract inherits.
26
+ const DEFAULT_ALLOWED_FIELDS = ["dashboards", "widgetTypes", "canvas", "dataModel"];
27
+
28
+ function safeString(value) {
29
+ if (value == null) return "";
30
+ return typeof value === "string" ? value : String(value);
31
+ }
32
+
33
+ function asArray(value) {
34
+ return Array.isArray(value) ? value : [];
35
+ }
36
+
37
+ function defaultContract() {
38
+ return {
39
+ role: "default",
40
+ allowedFields: DEFAULT_ALLOWED_FIELDS.slice(),
41
+ // Field groups that require a recorded preflight receipt before the write.
42
+ requiresReceiptFor: ["dataModel", "dashboards"],
43
+ // Field groups that require a human/super-admin review state.
44
+ requiresReviewFor: [],
45
+ // Live workflow changes must go draft → sandbox-run proof → publish.
46
+ liveWorkflowRequiresPublishProof: true
47
+ };
48
+ }
49
+
50
+ /**
51
+ * @param {object} mutation the proposed change.
52
+ * `{ changedFields: string[], touchesLiveWorkflow?: boolean, lane?: string }`
53
+ * @param {object} [contract] role/SKILL contract (see `defaultContract`).
54
+ * @param {object} [evidence] proof already gathered.
55
+ * `{ hasPreflightReceipt?: boolean, hasPublishProof?: boolean, reviewState?: string }`
56
+ * @returns {object} `{ kind, version, role, compliant, required[], satisfied[], missing[], violations[], nextAction, summary }`
57
+ */
58
+ function deriveContractCompliance(mutation, contract, evidence = {}) {
59
+ const rules = { ...defaultContract(), ...(contract && typeof contract === "object" ? contract : {}) };
60
+ const role = safeString(rules.role) || "default";
61
+
62
+ const empty = (warning) => ({
63
+ kind: CONTRACT_COMPLIANCE_KIND,
64
+ version: CONTRACT_COMPLIANCE_VERSION,
65
+ role,
66
+ compliant: false,
67
+ required: [],
68
+ satisfied: [],
69
+ missing: [],
70
+ violations: warning ? [{ code: "invalid_input", message: warning }] : [],
71
+ nextAction: null,
72
+ summary: warning || "No compliance computed."
73
+ });
74
+
75
+ if (!mutation || typeof mutation !== "object") return empty("mutation missing or malformed");
76
+
77
+ const changedFields = asArray(mutation.changedFields).map(safeString).filter(Boolean);
78
+ const allowed = new Set(asArray(rules.allowedFields).map(safeString));
79
+ const required = [];
80
+ const satisfied = [];
81
+ const missing = [];
82
+ const violations = [];
83
+
84
+ // 1. Allowlist — disallowed fields are a hard violation (rejected by the route).
85
+ for (const field of changedFields) {
86
+ if (!allowed.has(field)) {
87
+ violations.push({
88
+ code: "field_not_allowed",
89
+ field,
90
+ message: `Field "${field}" is outside the PATCH allowlist (${Array.from(allowed).join(", ")}).`
91
+ });
92
+ }
93
+ }
94
+
95
+ // 2. Preflight receipt requirement.
96
+ const receiptGroups = new Set(asArray(rules.requiresReceiptFor).map(safeString));
97
+ if (changedFields.some((f) => receiptGroups.has(f))) {
98
+ const req = { code: "preflight_receipt", message: "A recorded patch-preflight receipt is required." };
99
+ required.push(req);
100
+ (evidence.hasPreflightReceipt ? satisfied : missing).push(req);
101
+ }
102
+
103
+ // 3. Review state requirement.
104
+ const reviewGroups = new Set(asArray(rules.requiresReviewFor).map(safeString));
105
+ if (changedFields.some((f) => reviewGroups.has(f))) {
106
+ const req = { code: "review_state", message: "A human/super-admin review state is required (e.g. approved)." };
107
+ required.push(req);
108
+ const reviewOk = ["approved", "accepted", "merged"].includes(safeString(evidence.reviewState).toLowerCase());
109
+ (reviewOk ? satisfied : missing).push(req);
110
+ }
111
+
112
+ // 4. Live workflow proof chain.
113
+ if (mutation.touchesLiveWorkflow && rules.liveWorkflowRequiresPublishProof) {
114
+ const req = { code: "publish_proof", message: "Live workflow change requires draft → sandbox-run proof → publish." };
115
+ required.push(req);
116
+ (evidence.hasPublishProof ? satisfied : missing).push(req);
117
+ }
118
+
119
+ const compliant = violations.length === 0 && missing.length === 0;
120
+ const nextAction = violations.length
121
+ ? `Remove disallowed field(s): ${violations.filter((v) => v.code === "field_not_allowed").map((v) => v.field).join(", ") || "see violations"}.`
122
+ : (missing[0]
123
+ ? missingNextAction(missing[0])
124
+ : (compliant ? "Compliant — proceed through the governed PATCH/publish lane." : null));
125
+
126
+ return {
127
+ kind: CONTRACT_COMPLIANCE_KIND,
128
+ version: CONTRACT_COMPLIANCE_VERSION,
129
+ role,
130
+ compliant,
131
+ required,
132
+ satisfied,
133
+ missing,
134
+ violations,
135
+ nextAction,
136
+ summary: summarizeCompliance(role, compliant, required, missing, violations)
137
+ };
138
+ }
139
+
140
+ function missingNextAction(req) {
141
+ switch (req.code) {
142
+ case "preflight_receipt": return "Run POST /api/workspace/patch/preflight and record the receipt before PATCH.";
143
+ case "review_state": return "Obtain an approved review state before applying.";
144
+ case "publish_proof": return "Save a draft, prove it with sandbox-run, then POST /api/workspace/workflow/publish.";
145
+ default: return `Satisfy: ${req.message}`;
146
+ }
147
+ }
148
+
149
+ function summarizeCompliance(role, compliant, required, missing, violations) {
150
+ if (violations.length) {
151
+ return `Non-compliant (${role}): ${violations.length} hard violation(s) — ${violations[0].message}`;
152
+ }
153
+ if (compliant) {
154
+ return required.length
155
+ ? `Compliant (${role}): all ${required.length} requirement(s) satisfied.`
156
+ : `Compliant (${role}): no governed requirements triggered.`;
157
+ }
158
+ return `Non-compliant (${role}): ${missing.length} of ${required.length} requirement(s) unmet.`;
159
+ }
160
+
161
+ export {
162
+ CONTRACT_COMPLIANCE_KIND,
163
+ CONTRACT_COMPLIANCE_VERSION,
164
+ DEFAULT_ALLOWED_FIELDS,
165
+ defaultContract,
166
+ deriveContractCompliance,
167
+ summarizeCompliance
168
+ };