flow-execution.mjs
1,239 lines 39.5 KB
Raw
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 19 hours ago
1 /**
2 * Flow execution gate — run advancement, consent ledger, automatable orchestration stubs
3 * (Phase 7A-L3b).
4 *
5 * Run operational state mutates in the flow store `runs[]`. Durable knowledge outcomes route
6 * through proposals (review-before-write). External-agent grants (SD-5) never substitute for
7 * execution consent (SD-6).
8 *
9 * `FLOW_RUN_WRITES_ENABLED` and `FLOW_AUTOMATABLE_EXECUTION_ENABLED` default **off**.
10 *
11 * @see docs/FLOW-EXECUTION-GATE-CONTRACT-7A-L3.md
12 */
13
14 import fs from 'fs';
15 import path from 'path';
16 import { createHash, randomBytes } from 'crypto';
17
18 import {
19 loadFlowStore,
20 saveFlowStore,
21 getFlow,
22 FLOW_ID_RE,
23 FLOW_RUN_ID_RE,
24 SEMVER_RE,
25 buildFlowStepId,
26 } from './flow-store.mjs';
27 import { resolveFlowVisibleScopes } from './flow-scope.mjs';
28 import { hashActorLabel } from './external-agent.mjs';
29 import { FLOW_PROPOSAL_SOURCE, FLOW_REVIEW_QUEUE } from './flow-authoring.mjs';
30
31 export const FLOW_EXECUTION_POLICY_FILE = 'hub_flow_execution_policy.json';
32 export const FLOW_EXECUTION_CONSENTS_FILE = 'hub_flow_execution_consents.json';
33 export const FLOW_IN_FLIGHT_EXECUTIONS_FILE = 'hub_flow_in_flight_executions.json';
34
35 export const FLOW_RUN_SCHEMA = 'knowtation.flow_run/v0';
36 export const FLOW_RUN_START_SCHEMA = 'knowtation.flow_run_start/v0';
37 export const FLOW_RUN_LIST_SCHEMA = 'knowtation.flow_run_list/v0';
38 export const FLOW_EXECUTION_CONSENT_SCHEMA = 'knowtation.flow_execution_consent/v0';
39 export const FLOW_EXECUTION_CONSENT_MINT_SCHEMA = 'knowtation.flow_execution_consent_mint/v0';
40 export const FLOW_EXECUTE_AUTOMATABLE_SCHEMA = 'knowtation.flow_execute_automatable/v0';
41
42 export const CONSENT_ID_PREFIX = 'fcons_';
43 export const EXECUTION_ID_PREFIX = 'fexec_';
44 export const DEFAULT_CONSENT_TTL_SECONDS = 3600;
45 export const MAX_CONSENT_TTL_SECONDS = 86400;
46 export const DEFAULT_COST_CAP_UNITS = 100;
47
48 /** Bounded skip reasons for manual advance (never free-text alone). */
49 export const FLOW_SKIP_REASONS = ['policy', 'not_applicable', 'blocked_dependency'];
50
51 /** Internal skill-ref kinds allowed for automatable execution (never external_tool). */
52 export const AUTOMATABLE_SKILL_KINDS = new Set(['mcp_prompt', 'skill_pack', 'cli']);
53
54 /** @typedef {import('./flow-scope.mjs').FlowScope} FlowScope */
55
56 /** @param {unknown} v */
57 function envTriState(v) {
58 if (v === '1' || v === 'true') return true;
59 if (v === '0' || v === 'false') return false;
60 return null;
61 }
62
63 /**
64 * @param {string} dataDir
65 * @returns {object}
66 */
67 export function readFlowExecutionPolicyFile(dataDir) {
68 if (!dataDir) return {};
69 const fp = path.join(dataDir, FLOW_EXECUTION_POLICY_FILE);
70 try {
71 if (!fs.existsSync(fp)) return {};
72 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
73 return j && typeof j === 'object' ? j : {};
74 } catch {
75 return {};
76 }
77 }
78
79 /**
80 * @param {string} dataDir
81 * @returns {boolean}
82 */
83 export function getFlowRunWritesEnabled(dataDir) {
84 const fromEnv = envTriState(process.env.FLOW_RUN_WRITES_ENABLED);
85 if (fromEnv !== null) return fromEnv;
86 const policy = readFlowExecutionPolicyFile(dataDir);
87 if (typeof policy.flow_run_writes_enabled === 'boolean') {
88 return policy.flow_run_writes_enabled;
89 }
90 return false;
91 }
92
93 /**
94 * @param {string} dataDir
95 * @returns {boolean}
96 */
97 export function getFlowAutomatableExecutionEnabled(dataDir) {
98 const fromEnv = envTriState(process.env.FLOW_AUTOMATABLE_EXECUTION_ENABLED);
99 if (fromEnv !== null) return fromEnv;
100 const policy = readFlowExecutionPolicyFile(dataDir);
101 const exec = policy.execution;
102 if (exec && typeof exec === 'object' && typeof exec.automatable_enabled === 'boolean') {
103 return exec.automatable_enabled;
104 }
105 return false;
106 }
107
108 /**
109 * @param {string} dataDir
110 * @returns {boolean}
111 */
112 export function getFlowExecutionPolicyForbidden(dataDir) {
113 const fromEnv = envTriState(process.env.FLOW_EXECUTION_POLICY_FORBIDDEN);
114 if (fromEnv !== null) return fromEnv;
115 const policy = readFlowExecutionPolicyFile(dataDir);
116 const exec = policy.execution;
117 if (exec && typeof exec === 'object' && typeof exec.forbidden === 'boolean') {
118 return exec.forbidden;
119 }
120 return false;
121 }
122
123 /**
124 * @param {string} dataDir
125 * @returns {{
126 * allowedLanes: Set<string>,
127 * defaultCostCapUnits: number,
128 * defaultTtlSeconds: number,
129 * maxTtlSeconds: number,
130 * automatableForbidden: boolean,
131 * }}
132 */
133 export function readVaultExecutionPolicy(dataDir) {
134 const policy = readFlowExecutionPolicyFile(dataDir);
135 const exec = policy.execution && typeof policy.execution === 'object' ? policy.execution : {};
136 const lanes = new Set();
137 if (Array.isArray(exec.allowed_lanes)) {
138 for (const lane of exec.allowed_lanes) {
139 if (typeof lane === 'string' && lane.trim()) lanes.add(lane.trim());
140 }
141 }
142 if (lanes.size === 0) lanes.add('local_default');
143 const defaultCostCapUnits =
144 typeof exec.default_cost_cap_units === 'number' && exec.default_cost_cap_units > 0
145 ? exec.default_cost_cap_units
146 : DEFAULT_COST_CAP_UNITS;
147 const defaultTtlSeconds =
148 typeof exec.default_ttl_seconds === 'number' && exec.default_ttl_seconds > 0
149 ? exec.default_ttl_seconds
150 : DEFAULT_CONSENT_TTL_SECONDS;
151 const maxTtlSeconds =
152 typeof exec.max_ttl_seconds === 'number' && exec.max_ttl_seconds > 0
153 ? exec.max_ttl_seconds
154 : MAX_CONSENT_TTL_SECONDS;
155 const automatableForbidden =
156 typeof exec.automatable_forbidden === 'boolean' ? exec.automatable_forbidden : false;
157 return {
158 allowedLanes: lanes,
159 defaultCostCapUnits,
160 defaultTtlSeconds,
161 maxTtlSeconds,
162 automatableForbidden,
163 };
164 }
165
166 /**
167 * Import sandbox: reject bundles declaring non-manual automatable when policy forbids.
168 *
169 * @param {object[]} steps
170 * @param {string} dataDir
171 * @returns {{ ok: true } | { ok: false, denied: string[] }}
172 */
173 export function validateImportAutomatableSteps(steps, dataDir) {
174 const vaultPolicy = readVaultExecutionPolicy(dataDir);
175 if (!vaultPolicy.automatableForbidden) return { ok: true };
176 const denied = [];
177 for (const step of steps) {
178 if (step && step.automatable && step.automatable !== 'manual') {
179 denied.push(step.step_id ?? 'unknown');
180 }
181 }
182 if (denied.length > 0) return { ok: false, denied };
183 return { ok: true };
184 }
185
186 /**
187 * @param {object} run
188 * @returns {object}
189 */
190 export function runForClient(run) {
191 return {
192 schema: FLOW_RUN_SCHEMA,
193 run_id: run.run_id,
194 flow_id: run.flow_id,
195 flow_version: run.flow_version,
196 scope: run.scope,
197 status: run.status,
198 step_states: Array.isArray(run.step_states)
199 ? run.step_states.map((s) => ({
200 step_id: s.step_id,
201 status: s.status,
202 evidence_ref: s.evidence_ref ?? null,
203 verified: s.verified === true,
204 }))
205 : [],
206 started: run.started,
207 provenance: {
208 actor: run.provenance?.actor ?? '',
209 harness: run.provenance?.harness ?? 'unknown',
210 },
211 task_ref: typeof run.task_ref === 'string' ? run.task_ref : null,
212 external_ref: typeof run.external_ref === 'string' ? run.external_ref : null,
213 };
214 }
215
216 /**
217 * @param {object} consent
218 * @returns {object}
219 */
220 export function consentForClient(consent) {
221 return {
222 schema: FLOW_EXECUTION_CONSENT_SCHEMA,
223 consent_id: consent.consent_id,
224 vault_id: consent.vault_id,
225 scope: consent.scope,
226 run_id: consent.run_id,
227 flow_id: consent.flow_id,
228 flow_version: consent.flow_version,
229 allowed_lanes: consent.allowed_lanes,
230 cost_cap_units: consent.cost_cap_units,
231 cost_consumed_units: consent.cost_consumed_units,
232 actor_hash: consent.actor_hash,
233 expires_at: consent.expires_at,
234 revoked_at: consent.revoked_at ?? null,
235 };
236 }
237
238 /**
239 * @param {string} dataDir
240 * @returns {string}
241 */
242 function consentsFilePath(dataDir) {
243 return path.join(dataDir, FLOW_EXECUTION_CONSENTS_FILE);
244 }
245
246 /**
247 * @param {string} dataDir
248 * @returns {{ vaults: Record<string, { consents: object[] }> }}
249 */
250 export function loadExecutionConsentsStore(dataDir) {
251 const fp = consentsFilePath(dataDir);
252 if (!fs.existsSync(fp)) return { vaults: {} };
253 try {
254 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
255 if (!j || typeof j !== 'object') return { vaults: {} };
256 return { vaults: j.vaults && typeof j.vaults === 'object' ? j.vaults : {} };
257 } catch {
258 return { vaults: {} };
259 }
260 }
261
262 /**
263 * @param {string} dataDir
264 * @param {{ vaults: Record<string, { consents: object[] }> }} store
265 */
266 export function saveExecutionConsentsStore(dataDir, store) {
267 const fp = consentsFilePath(dataDir);
268 fs.mkdirSync(path.dirname(fp), { recursive: true });
269 fs.writeFileSync(fp, JSON.stringify(store, null, 2), 'utf8');
270 }
271
272 /**
273 * @param {string} dataDir
274 * @returns {string}
275 */
276 function inFlightFilePath(dataDir) {
277 return path.join(dataDir, FLOW_IN_FLIGHT_EXECUTIONS_FILE);
278 }
279
280 /**
281 * @param {string} dataDir
282 * @returns {{ entries: Record<string, object> }}
283 */
284 export function loadInFlightExecutionsStore(dataDir) {
285 const fp = inFlightFilePath(dataDir);
286 if (!fs.existsSync(fp)) return { entries: {} };
287 try {
288 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
289 if (!j || typeof j !== 'object') return { entries: {} };
290 return { entries: j.entries && typeof j.entries === 'object' ? j.entries : {} };
291 } catch {
292 return { entries: {} };
293 }
294 }
295
296 /**
297 * @param {string} dataDir
298 * @param {{ entries: Record<string, object> }} store
299 */
300 export function saveInFlightExecutionsStore(dataDir, store) {
301 const fp = inFlightFilePath(dataDir);
302 fs.mkdirSync(path.dirname(fp), { recursive: true });
303 fs.writeFileSync(fp, JSON.stringify(store, null, 2), 'utf8');
304 }
305
306 /**
307 * @param {string} runId
308 * @param {string} stepId
309 * @param {string} consentId
310 * @returns {string}
311 */
312 export function inFlightExecutionKey(runId, stepId, consentId) {
313 return `${runId}|${stepId}|${consentId}`;
314 }
315
316 /**
317 * ModelRuntimeAdapter orchestration stub — bounded evidence pointer, no prompts/completions.
318 *
319 * @param {{ lane: string, dryRun?: boolean }} input
320 * @returns {{ status: string, evidence_ref: string|null, cost_units: number }}
321 */
322 export function runModelOrchestrationStub(input) {
323 if (input.dryRun === true) {
324 return { status: 'completed', evidence_ref: null, cost_units: 0 };
325 }
326 const hash = createHash('sha256')
327 .update(`stub|${input.lane}|${Date.now()}`, 'utf8')
328 .digest('hex')
329 .slice(0, 32);
330 return { status: 'completed', evidence_ref: `hash_${hash}`, cost_units: 1 };
331 }
332
333 /**
334 * @param {object[]} steps
335 * @returns {boolean}
336 */
337 export function stepHasForbiddenExternalTool(steps) {
338 for (const step of steps) {
339 if (!Array.isArray(step?.skill_refs)) continue;
340 for (const ref of step.skill_refs) {
341 if (ref && ref.kind === 'external_tool') return true;
342 }
343 }
344 return false;
345 }
346
347 /**
348 * @param {object} ctx
349 * @returns {{ ok: false, status: number, error: string, code: string }}
350 */
351 function refuse(status, code, error) {
352 return { ok: false, status, error, code };
353 }
354
355 /**
356 * @param {object} input
357 * @returns {{ visibleScopes: Set<FlowScope>, ambiguous: boolean }}
358 */
359 function resolveHandlerScopes(input) {
360 if (input.ambiguous === true) {
361 return { visibleScopes: new Set(['personal']), ambiguous: true };
362 }
363 if (input.visibleScopes instanceof Set) {
364 return { visibleScopes: input.visibleScopes, ambiguous: false };
365 }
366 return resolveFlowVisibleScopes({
367 dataDir: input.dataDir,
368 userId: input.userId,
369 vaultId: input.vaultId,
370 role: input.role,
371 cliScopes: input.cliScopes,
372 });
373 }
374
375 /**
376 * @param {object} vault
377 * @param {string} runId
378 * @param {Set<FlowScope>} visibleScopes
379 * @returns {object|null}
380 */
381 function findVisibleRun(vault, runId, visibleScopes) {
382 if (!vault || !Array.isArray(vault.runs)) return null;
383 const run = vault.runs.find((r) => r.run_id === runId);
384 if (!run) return null;
385 if (!visibleScopes.has(run.scope)) return null;
386 return run;
387 }
388
389 /**
390 * @param {object} vault
391 * @param {string} flowId
392 * @param {string} stepId
393 * @returns {object|null}
394 */
395 function findStepDefinition(vault, flowId, stepId) {
396 if (!vault || !Array.isArray(vault.steps)) return null;
397 return vault.steps.find((s) => s.flow_id === flowId && s.step_id === stepId) ?? null;
398 }
399
400 /**
401 * @param {object} run
402 * @param {object} stepDef
403 * @param {object} stepState
404 * @returns {boolean}
405 */
406 function canMarkStepDone(stepDef, stepState) {
407 if (!stepDef?.verification?.evidence_required) return true;
408 return stepState.verified === true;
409 }
410
411 /**
412 * @param {object[]} stepStates
413 * @param {object[]} orderedSteps
414 * @returns {number}
415 */
416 export function frontierOrdinal(stepStates, orderedSteps) {
417 const stateById = new Map(stepStates.map((s) => [s.step_id, s]));
418 for (const step of orderedSteps) {
419 const state = stateById.get(step.step_id);
420 if (!state || state.status === 'pending' || state.status === 'in_progress') {
421 return step.ordinal;
422 }
423 if (state.status !== 'done' && state.status !== 'skipped') {
424 return step.ordinal;
425 }
426 }
427 return orderedSteps.length > 0 ? orderedSteps[orderedSteps.length - 1].ordinal + 1 : 1;
428 }
429
430 /**
431 * @param {object} input
432 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
433 */
434 export function handleFlowRunListRequest(input) {
435 const resolved = resolveHandlerScopes(input);
436 if (resolved.ambiguous) {
437 return refuse(400, 'FLOW_SCOPE_AMBIGUOUS', 'Ambiguous flow scope');
438 }
439
440 const flowId = typeof input.flowId === 'string' ? input.flowId.trim() : '';
441 if (flowId && !FLOW_ID_RE.test(flowId)) {
442 return refuse(400, 'BAD_REQUEST', 'Invalid flow id');
443 }
444
445 const store = loadFlowStore(input.dataDir);
446 const vault = store.vaults[input.vaultId];
447 const runs = vault && Array.isArray(vault.runs) ? vault.runs : [];
448 const filtered = runs.filter((r) => {
449 if (!resolved.visibleScopes.has(r.scope)) return false;
450 if (flowId && r.flow_id !== flowId) return false;
451 return true;
452 });
453
454 return {
455 ok: true,
456 payload: {
457 schema: FLOW_RUN_LIST_SCHEMA,
458 vault_id: input.vaultId,
459 runs: filtered.map(runForClient),
460 },
461 };
462 }
463
464 /**
465 * @param {object} input
466 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
467 */
468 export function handleFlowRunGetRequest(input) {
469 const runId = typeof input.runId === 'string' ? input.runId.trim() : '';
470 if (!runId || !FLOW_RUN_ID_RE.test(runId)) {
471 return refuse(400, 'BAD_REQUEST', 'Invalid run id');
472 }
473
474 const resolved = resolveHandlerScopes(input);
475 if (resolved.ambiguous) {
476 return refuse(400, 'FLOW_SCOPE_AMBIGUOUS', 'Ambiguous flow scope');
477 }
478
479 const store = loadFlowStore(input.dataDir);
480 const vault = store.vaults[input.vaultId];
481 const run = findVisibleRun(vault, runId, resolved.visibleScopes);
482 if (!run) {
483 return refuse(404, 'unknown_run', 'unknown_run');
484 }
485
486 return {
487 ok: true,
488 payload: {
489 schema: FLOW_RUN_SCHEMA,
490 vault_id: input.vaultId,
491 run: runForClient(run),
492 },
493 };
494 }
495
496 /**
497 * @param {object} input
498 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
499 */
500 export function handleFlowRunStartRequest(input) {
501 if (getFlowExecutionPolicyForbidden(input.dataDir)) {
502 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Execution forbidden by policy');
503 }
504 if (!getFlowRunWritesEnabled(input.dataDir)) {
505 return refuse(403, 'FLOW_RUN_WRITES_DISABLED', 'Run writes are disabled');
506 }
507
508 const flowId = typeof input.flowId === 'string' ? input.flowId.trim() : '';
509 const flowVersion = typeof input.flowVersion === 'string' ? input.flowVersion.trim() : '';
510 if (!flowId || !FLOW_ID_RE.test(flowId)) {
511 return refuse(400, 'BAD_REQUEST', 'Invalid flow id');
512 }
513 if (!flowVersion || !SEMVER_RE.test(flowVersion)) {
514 return refuse(400, 'BAD_REQUEST', 'Invalid flow version');
515 }
516
517 const resolved = resolveHandlerScopes(input);
518 if (resolved.ambiguous) {
519 return refuse(400, 'FLOW_SCOPE_AMBIGUOUS', 'Ambiguous flow scope');
520 }
521
522 const pinned = getFlow(input.dataDir, input.vaultId, flowId, {
523 filterScopes: resolved.visibleScopes,
524 version: flowVersion,
525 starterDir: input.starterDir,
526 });
527 if (!pinned) {
528 return refuse(404, 'unknown_flow', 'unknown_flow');
529 }
530
531 const store = loadFlowStore(input.dataDir);
532 if (!store.vaults[input.vaultId]) {
533 store.vaults[input.vaultId] = { flows: [], steps: [], runs: [], candidates: [], projections: [] };
534 }
535 const vault = store.vaults[input.vaultId];
536
537 const stepStates = pinned.steps.map((step) => ({
538 step_id: step.step_id,
539 status: 'pending',
540 evidence_ref: null,
541 verified: false,
542 }));
543
544 const runId = `run_${randomBytes(8).toString('hex')}`;
545 const actorHash = hashActorLabel(
546 typeof input.actorLabel === 'string' ? input.actorLabel : input.userId ?? 'actor',
547 input.vaultId,
548 input.userId ?? '',
549 );
550
551 /** @type {object} */
552 const run = {
553 schema: FLOW_RUN_SCHEMA,
554 run_id: runId,
555 flow_id: flowId,
556 flow_version: flowVersion,
557 scope: pinned.flow.scope,
558 status: 'in_progress',
559 step_states: stepStates,
560 started: new Date().toISOString(),
561 provenance: {
562 actor: actorHash,
563 harness: typeof input.harness === 'string' ? input.harness.trim() : 'hub',
564 },
565 task_ref: typeof input.taskRef === 'string' && input.taskRef.trim() ? input.taskRef.trim() : null,
566 external_ref:
567 typeof input.externalRef === 'string' && input.externalRef.trim() ? input.externalRef.trim() : null,
568 };
569
570 vault.runs.push(run);
571 saveFlowStore(input.dataDir, store);
572
573 return {
574 ok: true,
575 payload: {
576 schema: FLOW_RUN_START_SCHEMA,
577 run: runForClient(run),
578 },
579 };
580 }
581
582 /**
583 * @param {object} input
584 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
585 */
586 export function handleFlowRunAdvanceRequest(input) {
587 if (getFlowExecutionPolicyForbidden(input.dataDir)) {
588 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Execution forbidden by policy');
589 }
590 if (!getFlowRunWritesEnabled(input.dataDir)) {
591 return refuse(403, 'FLOW_RUN_WRITES_DISABLED', 'Run writes are disabled');
592 }
593
594 const runId = typeof input.runId === 'string' ? input.runId.trim() : '';
595 const stepId = typeof input.stepId === 'string' ? input.stepId.trim() : '';
596 const toStatus = typeof input.toStatus === 'string' ? input.toStatus.trim() : '';
597 const validStatuses = ['in_progress', 'blocked', 'done', 'skipped'];
598 if (!runId || !stepId || !validStatuses.includes(toStatus)) {
599 return refuse(400, 'BAD_REQUEST', 'Invalid advance request');
600 }
601
602 const resolved = resolveHandlerScopes(input);
603 if (resolved.ambiguous) {
604 return refuse(400, 'FLOW_SCOPE_AMBIGUOUS', 'Ambiguous flow scope');
605 }
606
607 const store = loadFlowStore(input.dataDir);
608 const vault = store.vaults[input.vaultId];
609 const runIdx = vault?.runs?.findIndex((r) => r.run_id === runId) ?? -1;
610 if (!vault || runIdx < 0) {
611 return refuse(404, 'unknown_run', 'unknown_run');
612 }
613 const run = vault.runs[runIdx];
614 if (!resolved.visibleScopes.has(run.scope)) {
615 return refuse(404, 'unknown_run', 'unknown_run');
616 }
617 if (run.status !== 'in_progress') {
618 return refuse(409, 'FLOW_RUN_NOT_IN_PROGRESS', 'Run is not in progress');
619 }
620
621 const pinned = getFlow(input.dataDir, input.vaultId, run.flow_id, {
622 filterScopes: resolved.visibleScopes,
623 version: run.flow_version,
624 starterDir: input.starterDir,
625 });
626 if (!pinned) {
627 return refuse(404, 'unknown_flow', 'unknown_flow');
628 }
629
630 const stepDef = findStepDefinition(vault, run.flow_id, stepId);
631 if (!stepDef) {
632 return refuse(400, 'BAD_REQUEST', 'Unknown step');
633 }
634
635 const frontier = frontierOrdinal(run.step_states, pinned.steps);
636 if (stepDef.ordinal > frontier) {
637 return refuse(409, 'FLOW_STEP_OUT_OF_ORDER', 'Step out of order');
638 }
639
640 if (toStatus === 'skipped') {
641 const skipReason = typeof input.skipReason === 'string' ? input.skipReason.trim() : '';
642 if (!FLOW_SKIP_REASONS.includes(skipReason)) {
643 return refuse(400, 'BAD_REQUEST', 'skip_reason required for skipped status');
644 }
645 }
646
647 const stateIdx = run.step_states.findIndex((s) => s.step_id === stepId);
648 if (stateIdx < 0) {
649 return refuse(400, 'BAD_REQUEST', 'Step not in run');
650 }
651 const stepState = run.step_states[stateIdx];
652
653 if (toStatus === 'done' && !canMarkStepDone(stepDef, stepState)) {
654 return refuse(403, 'FLOW_VERIFICATION_UNSATISFIED', 'Verification unsatisfied');
655 }
656
657 if (toStatus === 'in_progress') {
658 for (const s of run.step_states) {
659 if (s.status === 'in_progress' && s.step_id !== stepId) {
660 return refuse(409, 'FLOW_STEP_OUT_OF_ORDER', 'Another step is in progress');
661 }
662 }
663 }
664
665 run.step_states[stateIdx] = {
666 ...stepState,
667 status: toStatus,
668 evidence_ref: stepState.evidence_ref ?? null,
669 verified: toStatus === 'done' ? stepState.verified === true : stepState.verified,
670 };
671
672 const allDone = run.step_states.every((s) => s.status === 'done' || s.status === 'skipped');
673 if (allDone) {
674 run.status = 'done';
675 }
676
677 vault.runs[runIdx] = run;
678 saveFlowStore(input.dataDir, store);
679
680 return {
681 ok: true,
682 payload: {
683 schema: FLOW_RUN_SCHEMA,
684 run: runForClient(run),
685 },
686 };
687 }
688
689 /**
690 * @param {object} input
691 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
692 */
693 export function handleFlowRunEvidenceRequest(input) {
694 if (getFlowExecutionPolicyForbidden(input.dataDir)) {
695 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Execution forbidden by policy');
696 }
697 if (!getFlowRunWritesEnabled(input.dataDir)) {
698 return refuse(403, 'FLOW_RUN_WRITES_DISABLED', 'Run writes are disabled');
699 }
700
701 const runId = typeof input.runId === 'string' ? input.runId.trim() : '';
702 const stepId = typeof input.stepId === 'string' ? input.stepId.trim() : '';
703 const evidenceRef = typeof input.evidenceRef === 'string' ? input.evidenceRef.trim() : '';
704 const pointerKind = typeof input.pointerKind === 'string' ? input.pointerKind.trim() : '';
705 const validKinds = ['proposal', 'artifact', 'hash', 'test_result'];
706 if (!runId || !stepId || !evidenceRef || !validKinds.includes(pointerKind)) {
707 return refuse(400, 'BAD_REQUEST', 'Invalid evidence request');
708 }
709
710 const resolved = resolveHandlerScopes(input);
711 if (resolved.ambiguous) {
712 return refuse(400, 'FLOW_SCOPE_AMBIGUOUS', 'Ambiguous flow scope');
713 }
714
715 const store = loadFlowStore(input.dataDir);
716 const vault = store.vaults[input.vaultId];
717 const runIdx = vault?.runs?.findIndex((r) => r.run_id === runId) ?? -1;
718 if (!vault || runIdx < 0) {
719 return refuse(404, 'unknown_run', 'unknown_run');
720 }
721 const run = vault.runs[runIdx];
722 if (!resolved.visibleScopes.has(run.scope)) {
723 return refuse(404, 'unknown_run', 'unknown_run');
724 }
725
726 const stepDef = findStepDefinition(vault, run.flow_id, stepId);
727 if (!stepDef) {
728 return refuse(400, 'BAD_REQUEST', 'Unknown step');
729 }
730
731 const stateIdx = run.step_states.findIndex((s) => s.step_id === stepId);
732 if (stateIdx < 0) {
733 return refuse(400, 'BAD_REQUEST', 'Step not in run');
734 }
735
736 const verified =
737 stepDef.verification?.kind !== 'human_review' && stepDef.verification?.evidence_required === true;
738
739 run.step_states[stateIdx] = {
740 ...run.step_states[stateIdx],
741 evidence_ref: evidenceRef,
742 verified: verified ? true : run.step_states[stateIdx].verified,
743 };
744
745 vault.runs[runIdx] = run;
746 saveFlowStore(input.dataDir, store);
747
748 return {
749 ok: true,
750 payload: {
751 schema: FLOW_RUN_SCHEMA,
752 run: runForClient(run),
753 },
754 };
755 }
756
757 /**
758 * @param {object} input
759 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
760 */
761 export function handleFlowExecutionConsentMintRequest(input) {
762 if (getFlowExecutionPolicyForbidden(input.dataDir)) {
763 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Execution forbidden by policy');
764 }
765 if (!getFlowAutomatableExecutionEnabled(input.dataDir)) {
766 return refuse(403, 'FLOW_AUTOMATABLE_EXECUTION_DISABLED', 'Automatable execution is disabled');
767 }
768 if (!getFlowRunWritesEnabled(input.dataDir)) {
769 return refuse(403, 'FLOW_RUN_WRITES_DISABLED', 'Run writes are disabled');
770 }
771
772 const runId = typeof input.runId === 'string' ? input.runId.trim() : '';
773 if (!runId || !FLOW_RUN_ID_RE.test(runId)) {
774 return refuse(400, 'BAD_REQUEST', 'Invalid run id');
775 }
776
777 const allowedLanesRaw = input.allowedLanes;
778 if (!Array.isArray(allowedLanesRaw) || allowedLanesRaw.length === 0) {
779 return refuse(400, 'BAD_REQUEST', 'allowed_lanes must be non-empty');
780 }
781 const allowedLanes = [...new Set(allowedLanesRaw.map((l) => (typeof l === 'string' ? l.trim() : '')).filter(Boolean))];
782 if (allowedLanes.length === 0) {
783 return refuse(400, 'BAD_REQUEST', 'allowed_lanes must be non-empty');
784 }
785
786 let costCap = input.costCapUnits;
787 if (!Number.isInteger(costCap) || costCap < 1) {
788 return refuse(400, 'BAD_REQUEST', 'cost_cap_units must be a positive integer');
789 }
790
791 const vaultPolicy = readVaultExecutionPolicy(input.dataDir);
792 if (vaultPolicy.automatableForbidden) {
793 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Automatable steps forbidden');
794 }
795
796 for (const lane of allowedLanes) {
797 if (!vaultPolicy.allowedLanes.has(lane)) {
798 return refuse(403, 'FLOW_EXECUTION_LANE_DENIED', 'Lane not permitted');
799 }
800 }
801
802 if (costCap > vaultPolicy.defaultCostCapUnits) {
803 costCap = vaultPolicy.defaultCostCapUnits;
804 }
805
806 let ttlSeconds = input.ttlSeconds;
807 if (ttlSeconds !== undefined && ttlSeconds !== null) {
808 if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1) {
809 return refuse(400, 'BAD_REQUEST', 'ttl_seconds must be a positive integer');
810 }
811 if (ttlSeconds > vaultPolicy.maxTtlSeconds) {
812 ttlSeconds = vaultPolicy.maxTtlSeconds;
813 }
814 } else {
815 ttlSeconds = vaultPolicy.defaultTtlSeconds;
816 }
817
818 const resolved = resolveHandlerScopes(input);
819 if (resolved.ambiguous) {
820 return refuse(400, 'FLOW_SCOPE_AMBIGUOUS', 'Ambiguous flow scope');
821 }
822
823 const store = loadFlowStore(input.dataDir);
824 const vault = store.vaults[input.vaultId];
825 const run = findVisibleRun(vault, runId, resolved.visibleScopes);
826 if (!run) {
827 return refuse(404, 'unknown_run', 'unknown_run');
828 }
829
830 const consentId = `${CONSENT_ID_PREFIX}${randomBytes(12).toString('hex')}`;
831 const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
832 const actorHash = hashActorLabel(
833 typeof input.actorLabel === 'string' ? input.actorLabel : input.userId ?? 'actor',
834 input.vaultId,
835 input.userId ?? '',
836 );
837
838 const consent = {
839 schema: FLOW_EXECUTION_CONSENT_SCHEMA,
840 consent_id: consentId,
841 vault_id: input.vaultId,
842 scope: run.scope,
843 run_id: runId,
844 flow_id: run.flow_id,
845 flow_version: run.flow_version,
846 allowed_lanes: allowedLanes,
847 cost_cap_units: costCap,
848 cost_consumed_units: 0,
849 actor_hash: actorHash,
850 expires_at: expiresAt,
851 revoked_at: null,
852 };
853
854 const consentStore = loadExecutionConsentsStore(input.dataDir);
855 if (!consentStore.vaults[input.vaultId]) {
856 consentStore.vaults[input.vaultId] = { consents: [] };
857 }
858 consentStore.vaults[input.vaultId].consents.push(consent);
859 saveExecutionConsentsStore(input.dataDir, consentStore);
860
861 return {
862 ok: true,
863 payload: {
864 schema: FLOW_EXECUTION_CONSENT_MINT_SCHEMA,
865 consent: consentForClient(consent),
866 },
867 };
868 }
869
870 /**
871 * @param {string} dataDir
872 * @param {string} vaultId
873 * @param {string} consentId
874 * @param {string} runId
875 * @returns {object|null}
876 */
877 export function findValidConsent(dataDir, vaultId, consentId, runId) {
878 const store = loadExecutionConsentsStore(dataDir);
879 const vault = store.vaults[vaultId];
880 if (!vault || !Array.isArray(vault.consents)) return null;
881 const consent = vault.consents.find((c) => c.consent_id === consentId);
882 if (!consent) return null;
883 if (consent.revoked_at) return null;
884 if (consent.run_id !== runId) return null;
885 if (Date.parse(consent.expires_at) <= Date.now()) return null;
886 return consent;
887 }
888
889 /**
890 * @param {object} input
891 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
892 */
893 export function handleFlowRunExecuteAutomatableRequest(input) {
894 if (getFlowExecutionPolicyForbidden(input.dataDir)) {
895 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Execution forbidden by policy');
896 }
897 if (!getFlowAutomatableExecutionEnabled(input.dataDir)) {
898 return refuse(403, 'FLOW_AUTOMATABLE_EXECUTION_DISABLED', 'Automatable execution is disabled');
899 }
900 if (!getFlowRunWritesEnabled(input.dataDir)) {
901 return refuse(403, 'FLOW_RUN_WRITES_DISABLED', 'Run writes are disabled');
902 }
903
904 const runId = typeof input.runId === 'string' ? input.runId.trim() : '';
905 const stepId = typeof input.stepId === 'string' ? input.stepId.trim() : '';
906 const consentId = typeof input.consentId === 'string' ? input.consentId.trim() : '';
907 if (!runId || !stepId || !consentId) {
908 return refuse(400, 'BAD_REQUEST', 'run_id, step_id, and consent_id are required');
909 }
910
911 const dryRun = input.dryRun === true;
912 const modelLane =
913 typeof input.modelLane === 'string' && input.modelLane.trim() ? input.modelLane.trim() : 'local_default';
914
915 const resolved = resolveHandlerScopes(input);
916 if (resolved.ambiguous) {
917 return refuse(400, 'FLOW_SCOPE_AMBIGUOUS', 'Ambiguous flow scope');
918 }
919
920 const vaultPolicy = readVaultExecutionPolicy(input.dataDir);
921 if (vaultPolicy.automatableForbidden) {
922 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Automatable steps forbidden');
923 }
924
925 const consent = findValidConsent(input.dataDir, input.vaultId, consentId, runId);
926 if (!consent) {
927 const store = loadExecutionConsentsStore(input.dataDir);
928 const vaultConsents = store.vaults[input.vaultId]?.consents ?? [];
929 const any = vaultConsents.find((c) => c.consent_id === consentId);
930 if (any && any.run_id !== runId) {
931 return refuse(403, 'FLOW_EXECUTION_CONSENT_RUN_MISMATCH', 'Consent run mismatch');
932 }
933 return refuse(403, 'FLOW_EXECUTION_CONSENT_REQUIRED', 'Valid consent required');
934 }
935
936 if (!consent.allowed_lanes.includes(modelLane)) {
937 return refuse(403, 'FLOW_EXECUTION_LANE_DENIED', 'Lane not in consent');
938 }
939
940 const inflightKey = inFlightExecutionKey(runId, stepId, consentId);
941 const inflightStore = loadInFlightExecutionsStore(input.dataDir);
942 const inflight = inflightStore.entries[inflightKey];
943 if (inflight && inflight.status === 'in_flight') {
944 return {
945 ok: true,
946 payload: {
947 schema: FLOW_EXECUTE_AUTOMATABLE_SCHEMA,
948 run: runForClient(inflight.run),
949 execution: inflight.execution,
950 },
951 };
952 }
953
954 const store = loadFlowStore(input.dataDir);
955 const vault = store.vaults[input.vaultId];
956 const runIdx = vault?.runs?.findIndex((r) => r.run_id === runId) ?? -1;
957 if (!vault || runIdx < 0) {
958 return refuse(404, 'unknown_run', 'unknown_run');
959 }
960 const run = vault.runs[runIdx];
961 if (!resolved.visibleScopes.has(run.scope)) {
962 return refuse(404, 'unknown_run', 'unknown_run');
963 }
964 if (run.status !== 'in_progress') {
965 return refuse(409, 'FLOW_RUN_NOT_IN_PROGRESS', 'Run is not in progress');
966 }
967
968 const stepDef = findStepDefinition(vault, run.flow_id, stepId);
969 if (!stepDef) {
970 return refuse(400, 'BAD_REQUEST', 'Unknown step');
971 }
972 if (stepDef.automatable !== 'automatable') {
973 return refuse(400, 'FLOW_STEP_NOT_AUTOMATABLE', 'Step is not automatable');
974 }
975
976 if (stepDef.verification?.kind === 'human_review') {
977 return refuse(403, 'FLOW_VERIFICATION_UNSATISFIED', 'human_review cannot be auto-verified');
978 }
979
980 const pinned = getFlow(input.dataDir, input.vaultId, run.flow_id, {
981 filterScopes: resolved.visibleScopes,
982 version: run.flow_version,
983 starterDir: input.starterDir,
984 });
985 if (!pinned) {
986 return refuse(404, 'unknown_flow', 'unknown_flow');
987 }
988
989 const frontier = frontierOrdinal(run.step_states, pinned.steps);
990 const stateIdx = run.step_states.findIndex((s) => s.step_id === stepId);
991 if (stateIdx < 0 || stepDef.ordinal > frontier) {
992 return refuse(409, 'FLOW_STEP_OUT_OF_ORDER', 'Step out of order');
993 }
994
995 for (const ref of stepDef.skill_refs ?? []) {
996 if (ref.kind === 'external_tool') {
997 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'external_tool not allowed on execution path');
998 }
999 if (!AUTOMATABLE_SKILL_KINDS.has(ref.kind)) {
1000 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Skill ref kind not allowed');
1001 }
1002 }
1003
1004 const projectedCost = dryRun ? 0 : 1;
1005 if (consent.cost_consumed_units + projectedCost > consent.cost_cap_units) {
1006 return refuse(403, 'FLOW_EXECUTION_COST_CAPPED', 'Cost cap exceeded');
1007 }
1008
1009 const orchestration = runModelOrchestrationStub({ lane: modelLane, dryRun });
1010 const executionId = `${EXECUTION_ID_PREFIX}${randomBytes(12).toString('hex')}`;
1011 const completedAt = new Date().toISOString();
1012
1013 const execution = {
1014 execution_id: executionId,
1015 step_id: stepId,
1016 status: orchestration.status,
1017 evidence_ref: orchestration.evidence_ref,
1018 cost_units: orchestration.cost_units,
1019 model_lane: modelLane,
1020 completed_at: completedAt,
1021 };
1022
1023 if (!dryRun && orchestration.evidence_ref && stepDef.verification?.evidence_required) {
1024 run.step_states[stateIdx] = {
1025 ...run.step_states[stateIdx],
1026 evidence_ref: orchestration.evidence_ref,
1027 verified: stepDef.verification.kind !== 'human_review',
1028 status: run.step_states[stateIdx].status === 'pending' ? 'in_progress' : run.step_states[stateIdx].status,
1029 };
1030 }
1031
1032 if (!dryRun) {
1033 consent.cost_consumed_units += orchestration.cost_units;
1034 const consentStore = loadExecutionConsentsStore(input.dataDir);
1035 const consentVault = consentStore.vaults[input.vaultId];
1036 if (consentVault) {
1037 const cIdx = consentVault.consents.findIndex((c) => c.consent_id === consentId);
1038 if (cIdx >= 0) {
1039 consentVault.consents[cIdx] = { ...consentVault.consents[cIdx], cost_consumed_units: consent.cost_consumed_units };
1040 saveExecutionConsentsStore(input.dataDir, consentStore);
1041 }
1042 }
1043 vault.runs[runIdx] = run;
1044 saveFlowStore(input.dataDir, store);
1045 }
1046
1047 inflightStore.entries[inflightKey] = {
1048 status: 'in_flight',
1049 run,
1050 execution,
1051 };
1052 saveInFlightExecutionsStore(input.dataDir, inflightStore);
1053
1054 return {
1055 ok: true,
1056 payload: {
1057 schema: FLOW_EXECUTE_AUTOMATABLE_SCHEMA,
1058 run: runForClient(run),
1059 execution,
1060 },
1061 };
1062 }
1063
1064 /**
1065 * @param {object} input
1066 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
1067 */
1068 export function handleFlowRunSubmitReviewRequest(input) {
1069 if (getFlowExecutionPolicyForbidden(input.dataDir)) {
1070 return refuse(403, 'FLOW_EXECUTION_POLICY_FORBIDDEN', 'Execution forbidden by policy');
1071 }
1072 if (!getFlowRunWritesEnabled(input.dataDir)) {
1073 return refuse(403, 'FLOW_RUN_WRITES_DISABLED', 'Run writes are disabled');
1074 }
1075
1076 const runId = typeof input.runId === 'string' ? input.runId.trim() : '';
1077 const intent = typeof input.intent === 'string' ? input.intent.trim() : '';
1078 if (!runId || !intent) {
1079 return refuse(400, 'BAD_REQUEST', 'run_id and intent are required');
1080 }
1081
1082 if (typeof input.createProposal !== 'function') {
1083 return refuse(500, 'RUNTIME_ERROR', 'createProposal is required');
1084 }
1085
1086 const resolved = resolveHandlerScopes(input);
1087 if (resolved.ambiguous) {
1088 return refuse(400, 'FLOW_SCOPE_AMBIGUOUS', 'Ambiguous flow scope');
1089 }
1090
1091 const store = loadFlowStore(input.dataDir);
1092 const vault = store.vaults[input.vaultId];
1093 const run = findVisibleRun(vault, runId, resolved.visibleScopes);
1094 if (!run) {
1095 return refuse(404, 'unknown_run', 'unknown_run');
1096 }
1097
1098 const proposal = input.createProposal(input.dataDir, {
1099 intent,
1100 source: FLOW_PROPOSAL_SOURCE,
1101 review_queue: FLOW_REVIEW_QUEUE,
1102 external_ref: run.external_ref ?? undefined,
1103 body: JSON.stringify({ run_id: run.run_id, flow_id: run.flow_id, flow_version: run.flow_version }, null, 2),
1104 frontmatter: {
1105 type: 'flow_run_outcome',
1106 run_id: run.run_id,
1107 flow_id: run.flow_id,
1108 flow_version: run.flow_version,
1109 },
1110 });
1111
1112 return {
1113 ok: true,
1114 payload: {
1115 schema: FLOW_RUN_SCHEMA,
1116 run: runForClient(run),
1117 proposal_id: proposal.proposal_id,
1118 },
1119 };
1120 }
1121
1122 /**
1123 * MCP unified handler — action dispatch for flow_run tool.
1124 *
1125 * @param {object} input
1126 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
1127 */
1128 export function handleFlowRunMcpRequest(input) {
1129 const action = typeof input.action === 'string' ? input.action.trim() : '';
1130 switch (action) {
1131 case 'start':
1132 return handleFlowRunStartRequest({
1133 ...input,
1134 flowId: input.flowId ?? input.flow_id,
1135 flowVersion: input.flowVersion ?? input.flow_version,
1136 taskRef: input.taskRef ?? input.task_ref,
1137 externalRef: input.externalRef ?? input.external_ref,
1138 });
1139 case 'get':
1140 return handleFlowRunGetRequest({
1141 ...input,
1142 runId: input.runId ?? input.run_id,
1143 });
1144 case 'list':
1145 return handleFlowRunListRequest({
1146 ...input,
1147 flowId: input.flowId ?? input.flow_id,
1148 });
1149 case 'advance':
1150 return handleFlowRunAdvanceRequest({
1151 ...input,
1152 runId: input.runId ?? input.run_id,
1153 stepId: input.stepId ?? input.step_id,
1154 toStatus: input.toStatus ?? input.to_status,
1155 skipReason: input.skipReason ?? input.skip_reason,
1156 });
1157 case 'evidence':
1158 return handleFlowRunEvidenceRequest({
1159 ...input,
1160 runId: input.runId ?? input.run_id,
1161 stepId: input.stepId ?? input.step_id,
1162 evidenceRef: input.evidenceRef ?? input.evidence_ref,
1163 pointerKind: input.pointerKind ?? input.pointer_kind,
1164 });
1165 case 'execute_automatable':
1166 return handleFlowRunExecuteAutomatableRequest({
1167 ...input,
1168 runId: input.runId ?? input.run_id,
1169 stepId: input.stepId ?? input.step_id,
1170 consentId: input.consentId ?? input.consent_id,
1171 modelLane: input.modelLane ?? input.model_lane,
1172 dryRun: input.dryRun ?? input.dry_run,
1173 });
1174 case 'submit_review':
1175 return handleFlowRunSubmitReviewRequest({
1176 ...input,
1177 runId: input.runId ?? input.run_id,
1178 intent: input.intent,
1179 });
1180 case 'consent_mint':
1181 return handleFlowExecutionConsentMintRequest({
1182 ...input,
1183 runId: input.runId ?? input.run_id,
1184 allowedLanes: input.allowedLanes ?? input.allowed_lanes,
1185 costCapUnits: input.costCapUnits ?? input.cost_cap_units,
1186 ttlSeconds: input.ttlSeconds ?? input.ttl_seconds,
1187 });
1188 default:
1189 return refuse(400, 'BAD_REQUEST', 'Unknown flow_run action');
1190 }
1191 }
1192
1193 /**
1194 * @param {object[]} steps
1195 * @param {string} flowId
1196 * @param {string} [automatable]
1197 * @returns {object}
1198 */
1199 export function makeAutomatableFlowBundle(steps, flowId = 'flow_automatable_test', automatable = 'automatable') {
1200 const version = '1.0.0';
1201 const stepId = buildFlowStepId(flowId, 1);
1202 return {
1203 flow: {
1204 schema: 'knowtation.flow/v0',
1205 flow_id: flowId,
1206 title: 'Automatable test flow',
1207 version,
1208 scope: 'personal',
1209 summary: 'Flow with automatable step for execution gate tests.',
1210 tags: ['test'],
1211 steps: [stepId],
1212 inputs: [],
1213 vault_mirror_path: `meta/flows/${flowId.replace(/^flow_/, '')}.md`,
1214 updated: '2026-06-20T00:00:00Z',
1215 truncated: false,
1216 },
1217 steps: steps ?? [
1218 {
1219 schema: 'knowtation.flow_step/v0',
1220 step_id: stepId,
1221 flow_id: flowId,
1222 ordinal: 1,
1223 owned_job: 'Summarize notes',
1224 instruction: 'Summarize the weekly notes into a brief.',
1225 trigger: 'On request',
1226 when_not_to_run: 'When no notes exist',
1227 boundaries: ['Read only — untrusted text'],
1228 skill_refs: [{ kind: 'cli', id: 'knowtation search' }],
1229 output_shape: 'A short brief',
1230 verification: {
1231 kind: 'artifact_exists',
1232 evidence_required: true,
1233 description: 'Brief artifact exists',
1234 },
1235 automatable,
1236 },
1237 ],
1238 };
1239 }
File History 1 commit
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 19 hours ago