From ad7fcf951806675af0c226e69384bc6573b5e7c4 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 12:27:49 +0900 Subject: [PATCH 01/13] fix(interactive): reuse Standard projectRoot and Skill analyzer config options --- src/interactive/compile.mjs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/interactive/compile.mjs b/src/interactive/compile.mjs index 2d23021..43f6dfa 100644 --- a/src/interactive/compile.mjs +++ b/src/interactive/compile.mjs @@ -3,6 +3,7 @@ import {mkdir,open,rename,unlink} from 'node:fs/promises'; import {randomUUID} from 'node:crypto'; import path from 'node:path'; import {analyzeSource} from '../compiler/analyze.mjs'; +import {normalizeOptions,resolveProjectRoot} from '../compiler/source.mjs'; import {lowerInteractive} from '../compiler/interactive/lower.mjs'; import {emitInteractive} from '../compiler/interactive/emit.mjs'; @@ -12,12 +13,20 @@ export async function compileInteractive(file, options) { throw new TypeError('Expected .workflow.js or .workflow.ts file'); } if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Compile options object required'); - const known = new Set(['output','outDir','featureStage','targetVersion']); + // Match the Standard static analyzer's project and Skill discovery rules. + // Do NOT silently accept unrelated Standard runtime/emit options. + const known = new Set(['output','outDir','featureStage','targetVersion', + 'projectRoot','skillsRoot','skillCheck','remoteSkillHosts']); for (const key of Reflect.ownKeys(options)) { if (typeof key !== 'string' || !known.has(key)) throw new TypeError('Unknown Interactive compile option'); if (!Object.hasOwn(Object.getOwnPropertyDescriptor(options,key),'value')) throw new TypeError('Compile option accessors forbidden'); } const snapshotOptions = Object.fromEntries(Object.keys(options).map(key=>[key,options[key]])); + const analyzerOptions=normalizeOptions(Object.fromEntries( + ['projectRoot','skillsRoot','skillCheck','remoteSkillHosts'] + .filter(key=>Object.hasOwn(snapshotOptions,key)) + .map(key=>[key,snapshotOptions[key]]) + )); if (!['memory','file'].includes(snapshotOptions.output)) throw new TypeError('Invalid compile output'); if (snapshotOptions.featureStage !== 'P0-A') throw new TypeError('Only P0-A is implemented'); if (snapshotOptions.targetVersion !== undefined && snapshotOptions.targetVersion !== 'interactive-state-machine-v1') { @@ -27,9 +36,10 @@ export async function compileInteractive(file, options) { const cwd = process.cwd(); const filename = path.resolve(cwd,file); const outDir = snapshotOptions.outDir === undefined ? path.dirname(filename) : path.resolve(cwd,snapshotOptions.outDir); + const projectRoot=resolveProjectRoot(filename,analyzerOptions.projectRoot,cwd); const snapshot = await readFile(filename); // original source bytes, never imported const analysis = await analyzeSource(snapshot,{ - filename, projectRoot:path.dirname(filename), + ...analyzerOptions,filename,projectRoot, }); const lowered = lowerInteractive(analysis,{featureStage:options.featureStage}); if (!lowered.ir) return {code:'',diagnostics:[...lowered.diagnostics]}; From f5ecee5d1af0cc06714de18f692d8a6b9fb509db Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 12:28:01 +0900 Subject: [PATCH 02/13] types(interactive): declare Standard-compatible project and Skill compile options --- types/interactive.d.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/types/interactive.d.ts b/types/interactive.d.ts index 2a1caad..1d397ae 100644 --- a/types/interactive.d.ts +++ b/types/interactive.d.ts @@ -19,6 +19,14 @@ export interface InteractiveCompileOptions { output: 'memory' | 'file'; featureStage: 'P0-A'; outDir?: string; + /** Uses the Standard analyzer's nearest package.json discovery by default. */ + projectRoot?: string; + /** Standard-compatible static Skill root (default "skills"). */ + skillsRoot?: string; + /** Standard-compatible local Skill existence check (default true). */ + skillCheck?: boolean; + /** Standard-compatible allowed HTTPS Skill host names. */ + remoteSkillHosts?: string[]; targetVersion?: 'interactive-state-machine-v1'; } export interface InteractiveDiagnostic { From f8db071bb339e287efa31ea75e81b34591f77293 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 12:28:19 +0900 Subject: [PATCH 03/13] fix(interactive): pure Condition/Terminal ignores unrelated Host checkStep callback --- src/interactive/coordinator.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/interactive/coordinator.mjs b/src/interactive/coordinator.mjs index 10d72e6..ceb7548 100644 --- a/src/interactive/coordinator.mjs +++ b/src/interactive/coordinator.mjs @@ -175,8 +175,12 @@ function assertRuntime(program,runtime) { if (typeof runtime !== 'object' || !runtime || Array.isArray(runtime)) { throw new TypeError('InteractiveRuntime must be an object'); } - if (runtime.checkStep !== undefined) { - throw failure('FK_I_UNSUPPORTED_GRAPH','P0-A cannot silently skip checkStep'); + // A Host may expose shared Standard runtime callbacks. Ignore an unused + // checkStep only when the verified Program has no Agent/validation bindings. + // Agent P0-A still fails closed: we must not silently skip its checkStep. + if (runtime.checkStep !== undefined + && program.programTable.requiredBindings.length > 0) { + throw failure('FK_I_UNSUPPORTED_GRAPH','P0-A cannot silently skip Agent checkStep'); } for (const binding of program.programTable.requiredBindings) { if (binding.id === 'runtime.validateResult' && typeof runtime.validateResult !== 'function' From 601c6d3474bfa8ee901d9693ed060c087321f6a5 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 12:29:24 +0900 Subject: [PATCH 04/13] types(interactive): reflect harmless shared checkStep on no-Agent Programs --- types/interactive.d.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/types/interactive.d.ts b/types/interactive.d.ts index 1d397ae..a178b2a 100644 --- a/types/interactive.d.ts +++ b/types/interactive.d.ts @@ -151,8 +151,12 @@ export interface InteractiveSkillDescriptor { export interface InteractiveRuntime { skillResolver?: (descriptor: InteractiveSkillDescriptor) => string|Promise; validateResult?: (schema: Record, result: unknown) => string[]; - /** checkStep is deliberately NOT supported in P0-A. */ - checkStep?: never; + /** + * A shared Standard Host may expose checkStep. It is ignored only for a + * verified Program with zero Agent bindings; Agent P0-A rejects it rather + * than silently skipping a requested validation capability. + */ + checkStep?: unknown; } export interface InteractiveCoordinator { readonly policyHash: string; From 8a3b480773e02e7c32ed711a05703d48e5b06bed Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 12:30:23 +0900 Subject: [PATCH 05/13] test(interactive): Stage2 nested/remote Skill config, pure checkStep and pinned Host binding versions --- tests/interactive/stage2-integration.test.mjs | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 tests/interactive/stage2-integration.test.mjs diff --git a/tests/interactive/stage2-integration.test.mjs b/tests/interactive/stage2-integration.test.mjs new file mode 100644 index 0000000..cacd726 --- /dev/null +++ b/tests/interactive/stage2-integration.test.mjs @@ -0,0 +1,224 @@ +import test,{after} from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,mkdir,writeFile,readFile,rm} from 'node:fs/promises'; +import path from 'node:path'; +import {tmpdir} from 'node:os'; +import {analyzeSource} from '../../src/compiler/analyze.mjs'; +import {compileInteractive} from '../../src/interactive/compile.mjs'; +import {createSqliteCheckpointStore} from '../../src/interactive/sqlite-store.mjs'; +import {createInteractiveCoordinator} from '../../src/interactive/coordinator.mjs'; + +const roots=[]; +after(async()=>{for(const dir of roots)await rm(dir,{recursive:true,force:true})}); +const dsl="import {defineWorkflow,node,condition,terminal} from '@devcodex-labs/flowkit/dsl';\n"; +const named=(agent,extra="")=>dsl+ + "export default defineWorkflow({id:'stage2-contract',version:'1.0.0',name:'Stage2',nodes:["+ + "node('worker',{name:'Worker',"+agent+",inputs:[],output:{type:'object',properties:{answer:{type:'string'}}},retries:0,next:'done'"+ + extra+"}),terminal('done',{name:'Done',status:'succeeded'})]});"; +const pure=dsl+"export default defineWorkflow({id:'stage2-pure',version:'1.0.0',name:'Pure',nodes:["+ + "condition('gate',{name:'Gate',branches:[{when:'ready',next:'done'},{when:'true',next:'fail'}]}),"+ + "terminal('done',{name:'Done',status:'succeeded'}),"+ + "terminal('fail',{name:'Fail',status:'failed'})]});"; +async function project(){ + const root=await mkdtemp(path.join(tmpdir(),'fk-stage2-')); + roots.push(root); + const workflows=path.join(root,'workflows'); + await mkdir(workflows,{recursive:true}); + await writeFile(path.join(root,'package.json'),'{"type":"module","private":true}'); + return {root,workflows}; +} +async function runStore(t,root){ + const store=createSqliteCheckpointStore(path.join(root,'run.sqlite'),{namespace:'stage2'}); + t.after(()=>store.close()); + return store; +} +function hasError(compiled){return compiled.diagnostics.some(x=>x.severity==='error')} +function minimalRuntime(){ + return { + skillResolver:async()=>'write an answer', + validateResult:(_schema,result)=>typeof result?.answer==='string'?[]:['answer required'], + }; +} + +test('S2 nested project: Interactive and original Standard analyzer use same nearest package.json root',async()=>{ + const {root,workflows}=await project(); + await mkdir(path.join(root,'skills')); + await writeFile(path.join(root,'skills','writer.md'),'# Stable writer'); + const file=path.join(workflows,'task.workflow.js'); + const text=named("agent:'writer'"); + await writeFile(file,text); + const options={output:'memory',featureStage:'P0-A'}; + const compiled=await compileInteractive(file,options); + assert.equal(hasError(compiled),false,JSON.stringify(compiled.diagnostics)); + assert.equal(compiled.programIdentity.implementedStage,'P0-A'); + assert.ok(compiled.code.includes('writer')); + const standard=await analyzeSource(Buffer.from(text),{filename:file}); + assert.deepEqual(compiled.diagnostics,standard.diagnostics); + const same=await compileInteractive(file,{...options,projectRoot:root}); + assert.equal(compiled.code,same.code); + assert.deepEqual(compiled.programIdentity,same.programIdentity); + // An explicitly incorrect root gives the same original diagnostic as Standard. + const wrong=await compileInteractive(file,{...options,projectRoot:workflows}); + assert.ok(wrong.diagnostics.some(x=>x.code==='FK_C051')); + const original=await analyzeSource(Buffer.from(text),{ + filename:file,projectRoot:workflows, + }); + assert.deepEqual(wrong.diagnostics,original.diagnostics); +}); + +test('S2 custom skillsRoot and skillCheck match Standard static checks, no runtime Skill fetching',async()=>{ + const {root,workflows}=await project(); + const file=path.join(workflows,'custom.workflow.ts'); + const text=named("agent:'writer'").replace("status:'succeeded'","status:'succeeded' as const"); + await writeFile(file,text); + const base={output:'memory',featureStage:'P0-A'}; + const missing=await compileInteractive(file,base); + assert.ok(missing.diagnostics.some(d=>d.code==='FK_C051')); + const unchecked=await compileInteractive(file,{...base,skillCheck:false}); + assert.equal(hasError(unchecked),false,JSON.stringify(unchecked.diagnostics)); + await mkdir(path.join(root,'custom-skills')); + await writeFile(path.join(root,'custom-skills','writer.md'),'# Writer v2'); + const configured=await compileInteractive(file,{...base,skillsRoot:'custom-skills'}); + assert.equal(hasError(configured),false,JSON.stringify(configured.diagnostics)); + const standard=await analyzeSource(Buffer.from(text),{ + filename:file,skillsRoot:'custom-skills', + }); + assert.deepEqual(configured.diagnostics,standard.diagnostics); + const wrong=await compileInteractive(file,{...base,skillsRoot:'does-not-exist'}); + assert.ok(wrong.diagnostics.some(x=>x.code==='FK_C051')); + assert.deepEqual(wrong.diagnostics,(await analyzeSource(Buffer.from(text),{ + filename:file,skillsRoot:'does-not-exist', + })).diagnostics); +}); + +test('S2 HTTPS Skill allowlist and integrity match Standard analyzer; no network access',async()=>{ + const {root,workflows}=await project(); + const file=path.join(workflows,'remote.workflow.js'); + const integrity='sha256-'+Buffer.alloc(32,5).toString('base64'); + const text=named("skill:'https://skills.example.test/writer.md'",",integrity:'"+integrity+"'"); + await writeFile(file,text); + const base={output:'memory',featureStage:'P0-A'}; + const refused=await compileInteractive(file,base); + assert.ok(refused.diagnostics.some(x=>x.code==='FK_C057')); + const allowed=await compileInteractive(file,{...base,remoteSkillHosts:['skills.example.test']}); + assert.equal(hasError(allowed),false,JSON.stringify(allowed.diagnostics)); + assert.equal(allowed.diagnostics.some(x=>x.code==='FK_C059'),false); + const standard=await analyzeSource(Buffer.from(text),{ + filename:file,remoteSkillHosts:['skills.example.test'], + }); + assert.deepEqual(allowed.diagnostics,standard.diagnostics); + await assert.rejects(compileInteractive(file,{ + ...base,remoteSkillHosts:'skills.example.test', + }),/Invalid option type: remoteSkillHosts/); + const withoutIntegrity=text.replace(",integrity:'"+integrity+"'",""); + await writeFile(file,withoutIntegrity); + const warn=await compileInteractive(file,{...base,remoteSkillHosts:['skills.example.test']}); + assert.equal(hasError(warn),false); + assert.ok(warn.diagnostics.some(x=>x.code==='FK_C059'&&x.severity==='warning')); +}); + +test('S2 compiler refuses unrelated Standard options and accessors before any write',async()=>{ + const {root,workflows}=await project(); + const file=path.join(workflows,'simple.workflow.js'); + await writeFile(file,pure); + const base={output:'memory',featureStage:'P0-A'}; + await assert.rejects(compileInteractive(file,{...base,trace:true}),/Unknown Interactive/); + await assert.rejects(compileInteractive(file,{...base,mermaid:false}),/Unknown Interactive/); + await assert.rejects(compileInteractive(file,{...base,projectRoot:42}),/Invalid option type: projectRoot/); + await assert.rejects(compileInteractive(file,{...base,skillCheck:'yes'}),/Invalid option type: skillCheck/); + const accessor={...base}; + Object.defineProperty(accessor,'skillsRoot',{enumerable:true,get(){throw Error('should not run')}}); + await assert.rejects(compileInteractive(file,accessor),/accessors forbidden/i); + const bad={...base}; + bad.remoteSkillHosts=[null]; + await assert.rejects(compileInteractive(file,bad),/Invalid option type: remoteSkillHosts/); + const out=await compileInteractive(file,base); + assert.equal(hasError(out),false); + const disk=await compileInteractive(file,{...base,output:'file',outDir:root}); + assert.equal(await readFile(disk.artifactPath,'utf8'),out.code); +}); + +test('S2 harmless shared Host checkStep does not block pure Condition/Terminal, but Agent remains fail-closed',async t=>{ + const {root,workflows}=await project(); + const pureFile=path.join(workflows,'pure.workflow.js'); + await writeFile(pureFile,pure); + const pureProgram=await compileInteractive(pureFile,{output:'memory',featureStage:'P0-A'}); + assert.equal(hasError(pureProgram),false); + const store=await runStore(t,root); + const sharedRuntime={checkStep:async()=>true}; + const pureCoordinator=createInteractiveCoordinator({store,runtime:sharedRuntime}); + const succeeded=await pureCoordinator.start({ + programBytes:Buffer.from(pureProgram.code), + programIdentity:pureProgram.programIdentity, + runId:'shared-pure',startKey:'shared-pure',state:{ready:true}, + }); + assert.equal(succeeded.status,'succeeded'); + assert.equal((await pureCoordinator.resume({runId:succeeded.runId})).revision,succeeded.revision); + assert.deepEqual(succeeded.state,{ready:true}); + const agentFile=path.join(workflows,'agent.workflow.js'); + await writeFile(agentFile,named("agent:'writer'")); + const agentProgram=await compileInteractive(agentFile,{output:'memory',featureStage:'P0-A',skillCheck:false}); + assert.equal(hasError(agentProgram),false); + const agentCoordinator=createInteractiveCoordinator({store,runtime:{ + ...minimalRuntime(),checkStep:()=>true, + }}); + await assert.rejects(agentCoordinator.start({ + programBytes:Buffer.from(agentProgram.code), + programIdentity:agentProgram.programIdentity, + runId:'shared-agent',startKey:'shared-agent', + }),{code:'FK_I_UNSUPPORTED_GRAPH'}); + assert.equal(store.load('shared-agent'),null); +}); + +test('S2 Host-declared bindingVersions in existing policy hash prohibit drift on resume with no mutation',async t=>{ + const {root,workflows}=await project(); + const file=path.join(workflows,'agent.workflow.js'); + await writeFile(file,named("agent:'writer'")); + const compiled=await compileInteractive(file,{output:'memory',featureStage:'P0-A',skillCheck:false}); + assert.equal(hasError(compiled),false); + const store=await runStore(t,root); + const versions={ + 'runtime.skillResolver':'writer-skill/v1.sha256:a', + 'runtime.validateResult':'validator/v1.sha256:b', + }; + const runtime=minimalRuntime(); + const initialPolicy={deploymentId:'stage2',bindingVersions:versions}; + const api=createInteractiveCoordinator({store,runtime,policy:initialPolicy}); + const started=await api.start({ + programBytes:Buffer.from(compiled.code), + programIdentity:compiled.programIdentity, + runId:'version-pinned',startKey:'version-pinned', + }); + assert.equal(started.status,'waiting'); + const original=canonicalBytesFor(store.load(started.runId)); + const journal=store.listJournal(started.runId); + const evolved=createInteractiveCoordinator({store,runtime:{ + ...runtime,validateResult:()=>['new behavior'], + },policy:{ + deploymentId:'stage2',bindingVersions:{...versions, + 'runtime.validateResult':'validator/v2.sha256:c'}, + }}); + await assert.rejects(evolved.resume({runId:started.runId}),{code:'FK_I_POLICY_MISMATCH'}); + await assert.rejects(evolved.complete({ + runId:started.runId,requestId:started.pending.requestId, + nodeId:started.pending.nodeId,attempt:started.pending.attempt, + result:{answer:'x'}, + }),{code:'FK_I_POLICY_MISMATCH'}); + assert.deepEqual(canonicalBytesFor(store.load(started.runId)),original); + assert.deepEqual(store.listJournal(started.runId),journal); + const reconnected=createInteractiveCoordinator({store,runtime,policy:{ + deploymentId:'stage2',bindingVersions:{...versions}, + }}); + const completed=await reconnected.complete({ + runId:started.runId,requestId:started.pending.requestId, + nodeId:started.pending.nodeId,attempt:started.pending.attempt, + result:{answer:'x'}, + }); + assert.equal(completed.status,'succeeded'); + assert.equal(completed.state.answer,'x'); +}); +function canonicalBytesFor(value){ + // JSON serialization is sufficient for unmodified reference Store load + // byte comparisons here; test only equality, not a schema-level hash. + return Buffer.from(JSON.stringify(value)); +} From 533c083b6ea4bcaada84ad35fa0119c55be6d1b3 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 12:30:47 +0900 Subject: [PATCH 06/13] test(types): Stage2 Interactive compile config and shared Host callback declarations --- tests/types/interactive.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/types/interactive.ts b/tests/types/interactive.ts index 4ee35ed..446452a 100644 --- a/tests/types/interactive.ts +++ b/tests/types/interactive.ts @@ -6,7 +6,11 @@ import type { InteractiveCompileOptions, } from '../../types/interactive.js'; -const options: InteractiveCompileOptions={output:'memory',featureStage:'P0-A'}; +const options: InteractiveCompileOptions={ + output:'memory',featureStage:'P0-A', + projectRoot:'project',skillsRoot:'skills',skillCheck:true, + remoteSkillHosts:['skills.example.test'], +}; const compiled=compileInteractive('flow.workflow.ts',options); declare const artifact: InteractiveProgramIdentity; declare const bytes: Uint8Array; @@ -27,8 +31,10 @@ void [compiled,loader,start,finish,resume]; compileInteractive('x',{output:'memory',featureStage:'P0-B'}); // @ts-expect-error validators remain synchronous const asyncValidator: NonNullable=async()=>[]; -// @ts-expect-error no checkStep until P0-B -const unsupported: InteractiveRuntime={checkStep:()=>null}; +// A shared Standard Host's checkStep is a valid declaration; the Interactive +// Coordinator rejects it only when a P0-A Agent Program would silently skip it. +const sharedRuntime:InteractiveRuntime={checkStep:()=>null}; +void sharedRuntime; // @ts-expect-error required durable idempotency key coordinator.start({programBytes:bytes,programIdentity:artifact}); // @ts-expect-error correct attempt required on complete From 4091e0ce82bfe8d19d9e2f0aea021a33778d0b9c Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 12:33:11 +0900 Subject: [PATCH 07/13] docs(interactive): scope Stage2 Standard parity and Host-owned binding version contract --- docs/interactive-stage2-contract.md | 123 ++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/interactive-stage2-contract.md diff --git a/docs/interactive-stage2-contract.md b/docs/interactive-stage2-contract.md new file mode 100644 index 0000000..0d5262f --- /dev/null +++ b/docs/interactive-stage2-contract.md @@ -0,0 +1,123 @@ +# FlowKit Interactive — Stage 2 integration contracts + +> Internal / P0-A only. This document is about static compilation and Host binding +> compatibility, not a new Context module, artifact store, model service or Stage extension. + +## Project roots and Skill static validation + +`compileInteractive(file, {output:'memory'|'file',featureStage:'P0-A'})` +reuses the **original Standard analyzer** for static diagnostics. Its +project root is now discovered from the nearest ancestor `package.json`, as in +Standard `compile` and `check`. A Host may explicitly override this with +`projectRoot`, resolved against the process working directory. + +The Interactive compile options are deliberately restricted to the relevant +subset of the Standard static configuration contract: + +| Option | Behavior | +| --- | --- | +| `projectRoot` | Nearest ancestor `package.json` by default, else explicit root | +| `skillsRoot` | Default `skills`; used for `agent` → local Skill reference | +| `skillCheck` | Default true; false skips local file-existence checks only | +| `remoteSkillHosts` | Default empty allowlist for HTTPS Skill references | +| `outDir` | Optional output location for Interactive compiled Program | +| `output` | `memory` or `file` | +| `featureStage` | Strictly `P0-A` | +| `targetVersion` | Optional `interactive-state-machine-v1` | + +No remote Skill is downloaded by the compiler. Integrity/allowlist diagnostics +are inherited directly from Standard's static Skill checker. Options that +have no Interactive compile-time meaning (such as Standard tracing, +`mermaid`, live `fetch` callbacks) are rejected instead of silently ignored. +`flowkit.config.mjs` is still a **CLI-level** configuration convention: +the SDK does not silently execute a project configuration file. + +Example: + +```js +const program = await compileInteractive('workflows/review.workflow.js', { + output: 'memory', + featureStage: 'P0-A', + skillsRoot: 'skills', + remoteSkillHosts: ['skills.example.test'], +}); +``` + +A Host can also set `projectRoot`, or disable local existence checking in +environments where the trusted Host resolves Skills outside the compile machine. +Disabling `skillCheck` never authorizes unknown remote hosts or missing +Integrity requirements. + +## Host callback binding identity (existing policyHash contract) + +Interactive P0-A supports two Agent runtime bindings: +`runtime.skillResolver` and synchronous, pure `runtime.validateResult`. +These are Host code, not serialized inside the Program. + +The Coordinator already fingerprints the **entire Host-supplied `policy`** +as `policyHash`, persists that fingerprint at start, and rejects any +`resume` / `complete` / `load` under a different policy **before a +Checkpoint CAS**. No new SDK API or additional runtime is needed for binding +versions. The recommended Host policy is: + +```js +const policy = { + deploymentId: 'approved-host-2026-11', + bindingVersions: { + 'runtime.skillResolver': 'writer-package@2.3.0/sha256-', + 'runtime.validateResult': 'validator@1.5.0/sha256-', + }, +}; +const coordinator=createInteractiveCoordinator({store,runtime,policy}); +``` + +**Host obligations:** + +1. Derive each declared version from an approved deployment or immutable + resource manifest, including relevant Skill bytes, validator logic, + dependencies and externally loaded configuration. +2. Keep the actual implementation and referenced bytes consistent with the + declared version for the lifetime of an active Run. If a dependency changes + in a way that could change execution, bump its binding version **before** + the new Host attempts to resume. +3. Keep the original Host policy/implementation accessible until that Run + completes, or explicitly arrange a reviewed and audited migration. +4. Never treat an unchanged string version as cryptographic proof of unchanged + behavior. FlowKit does **not** introspect arbitrary JS closures, network + dependencies, or mutable files to discover their semantic identity. +5. `policy.bindingVersions` is **recommended and Host-enforced**, not yet + mandatory for legacy P0-A runs. Introducing a strict mandatory flag would + break already deployed Agent runs; it requires a separate compatibility + decision. P0-A relies on the Host to supply truthful version identities. + +Negative tests exercise changed version → `FK_I_POLICY_MISMATCH`, with the +Checkpoint and Journal unchanged, and the original version → successful +resume. It protects against **declared** policy drift, not undocumented +in-place changes in a Host callback. + +## Shared checkStep callback + +A generic Standard Host may expose `runtime.checkStep`. Presence alone +is harmless when the verified P0-A Program has **no Agent bindings** (for +example Condition → Terminal); the callback is never invoked. + +For an Agent Program, P0-A still rejects a non-undefined `checkStep` +rather than silently skipping a validation function it does not implement. +Agent verification and external `checkStep` remain reserved for P0-B. +The error is `FK_I_UNSUPPORTED_GRAPH` and no Run is created. + +## Program-load cost, measured separately + +`Coordinator.loadPinned` reads the immutable Program from SQLite, revalidates +its manifest, and calls the verified loader for every stage. This is safe but +may be unnecessarily expensive. We first benchmark repeated exact-byte loads +and a multi-phase workflow, with **no performance-SLA claim and no automatic +cache**. Any future cache must be keyed by immutable Program identity and +cannot bypass trust or artifact checks. See the recorded benchmark evidence +in the Stage 2 PR review. + +## Exit gate + +Keep all Standard six root API functions, Program ABI V1, signed M5 provenance, +S07 Checkpoint/receipt guarantees and stage gates unchanged. M6's S13 real +model Host execution remains separate and blocked until native evidence exists. From 820e17a94f96f0822b2d59c3701ba6296c4a6e52 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 12:33:47 +0900 Subject: [PATCH 08/13] bench(interactive): measure exact Program reload and 16-condition Coordinator stages before caching --- scripts/bench-interactive-load.mjs | 90 ++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 scripts/bench-interactive-load.mjs diff --git a/scripts/bench-interactive-load.mjs b/scripts/bench-interactive-load.mjs new file mode 100644 index 0000000..eeb1378 --- /dev/null +++ b/scripts/bench-interactive-load.mjs @@ -0,0 +1,90 @@ +/** + * Diagnostic-only benchmark, not a correctness/latency gate. + * Measures repeated exact-byte Program loads and a P0-A multi-stage Run. + * Never modifies the generated Program, security, or checkpoint semantics. + * + * Run after npm ci: node scripts/bench-interactive-load.mjs + */ +import {performance} from 'node:perf_hooks'; +import {mkdtemp,rm} from 'node:fs/promises'; +import path from 'node:path'; +import {tmpdir} from 'node:os'; +import {analyzeSource} from '../src/compiler/analyze.mjs'; +import {lowerInteractive} from '../src/compiler/interactive/lower.mjs'; +import {emitInteractive} from '../src/compiler/interactive/emit.mjs'; +import {loadInteractiveProgram} from '../src/interactive/load.mjs'; +import {createSqliteCheckpointStore} from '../src/interactive/sqlite-store.mjs'; +import {createInteractiveCoordinator} from '../src/interactive/coordinator.mjs'; + +const N=16; +const conditions=[]; +for(let i=0;ix.severity==='error')) throw new Error( + JSON.stringify(analysis.diagnostics)); +const ir=lowerInteractive(analysis); +if(!ir.ir) throw new Error(JSON.stringify(ir.diagnostics)); +const emitted=emitInteractive(ir.ir); +if(!emitted.code) throw new Error(JSON.stringify(emitted.diagnostics)); +const bytes=Buffer.from(emitted.code); +const hash=emitted.programIdentity.artifactHash; +const samples=[]; +for(let i=0;i<32;i++){ + const start=performance.now(); + await loadInteractiveProgram(bytes,hash); + if(i>=4)samples.push(performance.now()-start); +} +samples.sort((a,b)=>a-b); +const temp=await mkdtemp(path.join(tmpdir(),'fk-stage2-bench-')); +let store; +try{ + store=createSqliteCheckpointStore(path.join(temp,'run.sqlite'),{namespace:'stage2-bench'}); + let artifactReads=0; + let artifactReadMs=0; + const decorated={ + ...store, + getArtifact(digest){ + const start=performance.now(); + const result=store.getArtifact(digest); + artifactReads++; + artifactReadMs+=performance.now()-start; + return result; + }, + }; + const api=createInteractiveCoordinator({store:decorated,runtime:{}}); + const startedAt=performance.now(); + const completed=await api.start({ + programBytes:bytes,programIdentity:emitted.programIdentity, + runId:'stage2-bench-1',startKey:'stage2-bench-1',state:{ready:true}, + }); + const totalMs=performance.now()-startedAt; + if(completed.status!=='succeeded' || completed.state.ready!==true) { + throw new Error('Benchmark workflow did not reach successful terminal'); + } + const revisions=store.listJournal(completed.runId).length; + console.log('STAGE2_PROGRAM_LOAD_BENCH '+JSON.stringify({ + schemaVersion:1,nodeVersion:process.version,os:process.platform, + programBytes:bytes.length,conditionNodes:N, + loadSamples:samples.length, + loadMedianMs:Number(samples[Math.floor(samples.length/2)].toFixed(3)), + loadP90Ms:Number(samples[Math.floor(samples.length*.9)].toFixed(3)), + totalRunMs:Number(totalMs.toFixed(3)), + artifactReads, + artifactReadTotalMs:Number(artifactReadMs.toFixed(3)), + journalRevisions:revisions, + finalStatus:completed.status, + note:'single-run sampled diagnostics; no production SLA or cache change', + })); +}finally{ + try{store?.close()}finally{await rm(temp,{force:true,recursive:true})} +} From 9dd0dd105c1c67bdf6252c1f64f22e01a3185d61 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 12:34:01 +0900 Subject: [PATCH 09/13] ci(interactive): collect Stage2 Program reload benchmark on Ubuntu Node24 --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c892d1..bc69d4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: # Type rejection fixtures retain diagnostics in JSON evidence. echo "::remove-matcher owner=tsc::" npm run verify + - name: Measure repeated Interactive Program loads (diagnostic, Ubuntu Node 24) + if: ${{ matrix.os == 'ubuntu-24.04' && matrix.node == '24.22.1' }} + run: node scripts/bench-interactive-load.mjs - name: Consume candidate package through all documentation examples env: FLOWKIT_DOCS_EVIDENCE: .tmp/docs/candidate.json From 98f949117388f0acc9d4948cbb32407d558b9bfc Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 12:37:28 +0900 Subject: [PATCH 10/13] perf(interactive): 4-entry verified Program LRU while rechecking Store bytes/identity each phase --- src/interactive/coordinator.mjs | 39 ++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/src/interactive/coordinator.mjs b/src/interactive/coordinator.mjs index ceb7548..1ce6460 100644 --- a/src/interactive/coordinator.mjs +++ b/src/interactive/coordinator.mjs @@ -237,6 +237,12 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt const policySnapshot = copy(policy); const policyHash = fingerprint(policySnapshot); const stageBudget = validStageCount(maxStagesPerCall); + // Only verified immutable Program objects are cached, never Store records. + // Even on a cache hit every stage re-reads the Host's Store, checks exact + // Program bytes and identity, then verifies the current Run phase/policy. + // Small fixed LRU avoids a broad, implicit process-global cache. + const verifiedPrograms = new Map(); + const MAX_VERIFIED_PROGRAMS = 4; async function loadPinned(cp) { if (cp.namespace !== store.namespace || cp.policyHash !== policyHash) { @@ -244,14 +250,31 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt } assertSHA256(cp.artifactHash); const saved = store.getArtifact(cp.artifactHash); - if (!saved || saved.programIdentity.artifactHash !== cp.artifactHash) { - throw failure('FK_I_PROGRAM_MISMATCH','Missing compiled Program bytes'); - } - const program = await loadInteractiveProgram(saved.bytes,cp.artifactHash); - const declared = {...program.interactiveProgram.identity,artifactHash:cp.artifactHash}; - if (canonicalBytes(declared).toString('hex') - !== canonicalBytes(saved.programIdentity).toString('hex')) { - throw failure('FK_I_PROGRAM_MISMATCH','Stored Program manifest mismatch'); + if (!saved || saved.programIdentity?.artifactHash !== cp.artifactHash + || !(saved.bytes instanceof Uint8Array) + || sha256Bytes(saved.bytes) !== cp.artifactHash) { + throw failure('FK_I_PROGRAM_MISMATCH','Missing or modified compiler Program bytes'); + } + const identityBytes=canonicalBytes(saved.programIdentity); + const cached=verifiedPrograms.get(cp.artifactHash); + let program; + if (cached && cached.identityBytes.equals(identityBytes)) { + program=cached.program; + // Update LRU order; no change to the frozen Program or Store. + verifiedPrograms.delete(cp.artifactHash); + verifiedPrograms.set(cp.artifactHash,cached); + } else { + // Never trust stale cached ABI/identity after a Store metadata change. + program=await loadInteractiveProgram(saved.bytes,cp.artifactHash); + const declared={...program.interactiveProgram.identity,artifactHash:cp.artifactHash}; + if (!canonicalBytes(declared).equals(identityBytes)) { + throw failure('FK_I_PROGRAM_MISMATCH','Stored Program manifest mismatch'); + } + verifiedPrograms.delete(cp.artifactHash); + verifiedPrograms.set(cp.artifactHash,{program,identityBytes}); + if (verifiedPrograms.size>MAX_VERIFIED_PROGRAMS) { + verifiedPrograms.delete(verifiedPrograms.keys().next().value); + } } assertRuntime(program,execution); confirmCurrentBlock(cp,program); From 63b85995e159bf85a80d004153785acef0f274a2 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 12:38:15 +0900 Subject: [PATCH 11/13] test(interactive): verify Program cache re-reads and rejects tampered/missing Store bytes and metadata --- .../interactive/stage2-program-cache.test.mjs | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/interactive/stage2-program-cache.test.mjs diff --git a/tests/interactive/stage2-program-cache.test.mjs b/tests/interactive/stage2-program-cache.test.mjs new file mode 100644 index 0000000..4b7c44b --- /dev/null +++ b/tests/interactive/stage2-program-cache.test.mjs @@ -0,0 +1,110 @@ +import test,{after} from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,rm} from 'node:fs/promises'; +import path from 'node:path'; +import {tmpdir} from 'node:os'; +import {analyzeSource} from '../../src/compiler/analyze.mjs'; +import {lowerInteractive} from '../../src/compiler/interactive/lower.mjs'; +import {emitInteractive} from '../../src/compiler/interactive/emit.mjs'; +import {createInteractiveCoordinator} from '../../src/interactive/coordinator.mjs'; +import {createSqliteCheckpointStore} from '../../src/interactive/sqlite-store.mjs'; +import {canonicalBytes} from '../../src/interactive/canonical.mjs'; + +const roots=[]; +after(async()=>{for(const x of roots)await rm(x,{recursive:true,force:true})}); +const src=[ + "import {defineWorkflow,condition,terminal} from '@devcodex-labs/flowkit/dsl';", + "export default defineWorkflow({id:'s2-cache',version:'1.0.0',name:'Cache',nodes:[", + "condition('gate',{name:'Gate',branches:[{when:'ready',next:'done'},{when:'true',next:'fail'}]}),", + "terminal('done',{name:'Done',status:'succeeded'}),", + "terminal('fail',{name:'Fail',status:'failed'})]});", +].join('\n'); +async function fixture(t,maxStagesPerCall=1){ + const dir=await mkdtemp(path.join(tmpdir(),'fk-s2-cache-')); + roots.push(dir); + const analysis=await analyzeSource(src,{filename:'cache.workflow.js', + projectRoot:dir,skillCheck:false}); + assert.equal(analysis.diagnostics.some(x=>x.severity==='error'),false, + JSON.stringify(analysis.diagnostics)); + const ir=lowerInteractive(analysis); + assert.ok(ir.ir,JSON.stringify(ir.diagnostics)); + const compiled=emitInteractive(ir.ir); + assert.ok(compiled.code); + const program={ + programBytes:Buffer.from(compiled.code), + programIdentity:compiled.programIdentity, + }; + const store=createSqliteCheckpointStore(path.join(dir,'cache.sqlite'),{namespace:'s2-cache'}); + t.after(()=>store.close()); + let corruption=null,artifactReads=0; + const verifiedStore={ + ...store, + getArtifact(hash){ + artifactReads++; + const saved=store.getArtifact(hash); + if(!saved)return saved; + if(corruption==='missing')return null; + if(corruption==='bytes'){ + const changed=Buffer.from(saved.bytes); + changed[0]^=1; + return {...saved,bytes:changed}; + } + if(corruption==='identity')return { + ...saved,programIdentity:{...saved.programIdentity,compilerVersion:'spoofed-version'}, + }; + return saved; + }, + }; + const api=createInteractiveCoordinator({ + store:verifiedStore,runtime:{checkStep:()=>true}, + maxStagesPerCall, + }); + return { + store,api,program, + setCorruption(value){corruption=value}, + get reads(){return artifactReads}, + }; +} +async function complete(api,frame){ + for(let i=0;i<25 && frame.status==='paused';i++){ + frame=await api.resume({runId:frame.runId}); + } + assert.equal(frame.status,'succeeded'); + return frame; +} + +test('Stage2 Program cache is never trusted before re-reading exact Store bytes/identity/policy',async t=>{ + const f=await fixture(t,1); + const frame=await f.api.start({...f.program,runId:'cache-run',startKey:'cache-run', + state:{ready:true,checkpoint:'keep'}}); + assert.equal(frame.status,'paused'); + assert.ok(f.reads>=1); + const baseline=canonicalBytes(f.store.load(frame.runId)); + const beforeJournal=f.store.listJournal(frame.runId); + for(const mode of ['missing','bytes','identity']){ + const beforeReads=f.reads; + f.setCorruption(mode); + await assert.rejects(f.api.resume({runId:frame.runId}),error=> + error.code==='FK_I_PROGRAM_MISMATCH' || /Program manifest mismatch|compiled Program/.test(error.message)); + assert.ok(f.reads>beforeReads,'Store must be consulted even with a warm cache'); + assert.deepEqual(canonicalBytes(f.store.load(frame.runId)),baseline); + assert.deepEqual(f.store.listJournal(frame.runId),beforeJournal); + } + f.setCorruption(null); + const completed=await complete(f.api,frame); + assert.deepEqual(completed.state,{ready:true,checkpoint:'keep'}); + assert.equal(f.store.listJournal(frame.runId).length,completed.revision+1); +}); + +test('Stage2 repeated safe loads keep original phase/Journaling invariants',async t=>{ + const f=await fixture(t,1); + const frame=await f.api.start({...f.program,runId:'cache-repeat',startKey:'cache-repeat', + state:{ready:true}}); + assert.equal(frame.status,'paused'); + const result=await complete(f.api,frame); + assert.ok(f.reads>=3,'Program bytes are re-read and checked at each phase'); + assert.equal(result.state.ready,true); + assert.equal((await f.api.load({runId:result.runId})).revision,result.revision); + assert.equal((await f.api.resume({runId:result.runId})).status,'succeeded'); + assert.equal(f.store.listJournal(result.runId).length,result.revision+1); +}); From ad0758f2a5661a2da8d524e210167971c9c17f57 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 12:39:31 +0900 Subject: [PATCH 12/13] docs(interactive): record measured Stage2 Program reload cost and safe verified LRU constraints --- docs/interactive-stage2-contract.md | 31 +++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/interactive-stage2-contract.md b/docs/interactive-stage2-contract.md index 0d5262f..8f4b2f9 100644 --- a/docs/interactive-stage2-contract.md +++ b/docs/interactive-stage2-contract.md @@ -85,7 +85,7 @@ const coordinator=createInteractiveCoordinator({store,runtime,policy}); 4. Never treat an unchanged string version as cryptographic proof of unchanged behavior. FlowKit does **not** introspect arbitrary JS closures, network dependencies, or mutable files to discover their semantic identity. -5. `policy.bindingVersions` is **recommended and Host-enforced**, not yet +5. `policy.bindingVersions` is **recommended and Host-maintained**, not yet mandatory for legacy P0-A runs. Introducing a strict mandatory flag would break already deployed Agent runs; it requires a separate compatibility decision. P0-A relies on the Host to supply truthful version identities. @@ -106,15 +106,26 @@ rather than silently skipping a validation function it does not implement. Agent verification and external `checkStep` remain reserved for P0-B. The error is `FK_I_UNSUPPORTED_GRAPH` and no Run is created. -## Program-load cost, measured separately - -`Coordinator.loadPinned` reads the immutable Program from SQLite, revalidates -its manifest, and calls the verified loader for every stage. This is safe but -may be unnecessarily expensive. We first benchmark repeated exact-byte loads -and a multi-phase workflow, with **no performance-SLA claim and no automatic -cache**. Any future cache must be keyed by immutable Program identity and -cannot bypass trust or artifact checks. See the recorded benchmark evidence -in the Stage 2 PR review. +## Program-load cost and bounded verified cache + +The initial sampled benchmark on Ubuntu Node 24.22.1 measured a 15,908-byte +immutable Program across 16 Condition nodes: repeated verified loader +median **5.386 ms**, p90 **9.068 ms** (28 warm samples), with **34** +artifact reads and **147.468 ms** full Run time. These are +single-run diagnostic samples, **not a production SLA**. + +Because per-phase loader re-parsing is meaningful relative to the overall +execution time, the Coordinator now maintains a **per-instance, 4-entry LRU** +of *already-verified immutable Program objects*. It never stores the Program +in an external or global cache. At **every stage, including cache hits**, +FlowKit still reads the Host-owned Store, verifies the full Program byte hash +and manifest identity, checks the current policy, runtime binding requirements, +and legal Checkpoint phase. A changed/missing Store artifact rejects even +with a warm cache; this is covered by explicit negative tests. + +`node scripts/bench-interactive-load.mjs` measures the same workflow without +latency thresholds. The precise Stage 2 PR CI evidence records both before +and after results; timings vary by OS/Node and must not be treated as SLA. ## Exit gate From 45597554602518b1bfd20cae3ab72d2c59f2fa98 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 12:39:47 +0900 Subject: [PATCH 13/13] docs(interactive): link Stage2 Standard config, policy versions, and verified cache contract --- docs/interactive.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/interactive.md b/docs/interactive.md index a48139a..07dda2f 100644 --- a/docs/interactive.md +++ b/docs/interactive.md @@ -11,6 +11,18 @@ - **Not implemented in P0-A**: Human, Action, Verification, Loop, Parallel, Agent verify or external checkStep, automatic `runAgent` invocation, trusted Human provenance bridge, background service, P0-B/P1/P2 extensions. - The Coordinator and the Store are **owned and invoked by the Host**, not by the model. A model must not have direct filesystem/database access or direct `complete()` authority. +## Static configuration and Host binding version consistency + +The Interactive compiler now reuses Standard's project-root discovery and +Skill static validation options (`projectRoot`, `skillsRoot`, `skillCheck`, +`remoteSkillHosts`). Versioned Host validation/Skill bindings are already +pinned through the existing `policyHash` when the Host declares +`policy.bindingVersions`. Pure Condition/Terminal Programs tolerate an unused +shared `checkStep` callback; Agent P0-A still rejects one. + +See [Stage 2 integration contracts](./interactive-stage2-contract.md) for +Host obligations, compatibility limits and verified Program cache behavior. + ## Low-level Host-only example: compile, start, resolve pending, resume The source is a validated `*.workflow.js` or `*.workflow.ts` using