Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2b3eb27
docs(interactive): freeze scoped S07 capacity reserve and execution-o…
rockyshi1993 Nov 11, 2026
3fafdd7
fix(interactive): S07 reserve terminal error capacity and bound inlin…
rockyshi1993 Nov 11, 2026
4e00ad3
fix(interactive): S07 active CP reserve and immutable SQLite receipt …
rockyshi1993 Nov 11, 2026
41904e1
fix(interactive): deterministic S07 capacity terminal, bounded Agent …
rockyshi1993 Nov 11, 2026
eb8063f
fix(interactive): M5 signed historical Ack resolves immutable Store r…
rockyshi1993 Nov 11, 2026
0493247
refactor(interactive): keep receipt cache limit owned only by Coordin…
rockyshi1993 Nov 11, 2026
2c70aea
fix(interactive): matched oversized Agent results fail durably before…
rockyshi1993 Nov 11, 2026
af70fe7
test(interactive): S07 active reserve, permanent-waiting exit, 21-rec…
rockyshi1993 Nov 11, 2026
79002d8
types(interactive): optional immutable accepted-receipt Store lookup …
rockyshi1993 Nov 11, 2026
fd30e00
test(interactive): S07 pre-upgrade near-hard Run and capacity failure…
rockyshi1993 Nov 11, 2026
59ef879
docs(interactive): surface S07 capacity exits and immutable receipt i…
rockyshi1993 Nov 11, 2026
9e10e7a
test(interactive): signed M5 oversized result becomes durable S07 ter…
rockyshi1993 Nov 11, 2026
b205114
fix(interactive): normalize initial Checkpoint >2MiB failures to dete…
rockyshi1993 Nov 11, 2026
a849c95
fix(interactive): deterministic capacity error when initial State alr…
rockyshi1993 Nov 11, 2026
9f24a23
test(interactive): verify both initial >1.75MiB soft and >2MiB hard e…
rockyshi1993 Nov 11, 2026
1352c31
fix(interactive): make oversize start-intent fingerprint reject with …
rockyshi1993 Nov 11, 2026
841e54c
refactor(interactive): remove arbitrary 128KiB result limit; use actu…
rockyshi1993 Nov 11, 2026
744b470
docs(interactive): S07 size admission is entire Checkpoint, no arbitr…
rockyshi1993 Nov 11, 2026
356f575
docs(interactive): size admission based on whole Run Checkpoint, not …
rockyshi1993 Nov 11, 2026
61c03e7
test(interactive): admit 256KiB inline result when room exists, rejec…
rockyshi1993 Nov 11, 2026
8cd690c
fix(interactive): guard full receipt envelope before snapshot to avoi…
rockyshi1993 Nov 11, 2026
94bad70
test(interactive): S07 near-hard result whose receipt envelope overfl…
rockyshi1993 Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/interactive-s07-capacity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# FlowKit Interactive P0-A — S07 bounded Checkpoint / receipt liveness contract

> Stage 1 P1 fix only. FlowKit is a deterministic JavaScript workflow engine, **not** a business artifact repository or Context service. No new user file storage, object store, context graph, GC or TTL is introduced here.

## Capacity, identity and explicit outcomes

- The **hard** canonical JSON Checkpoint limit remains **2 MiB**, preserving its existing external corruption/read guard.
- The Store reserves **256 KiB** of capacity for deterministic failure and recovery. Creation and every **nonterminal** transition must stay at or below **1.75 MiB** (`MAX_ACTIVE_CHECKPOINT_BYTES`); this includes Pending preparation/publication, so an impossible-to-recover Pending is not published merely because its waiting snapshot fit the absolute maximum.
- There is **no arbitrary per-Agent 128 KiB limit**. Agent result JSON has the existing **2 MiB hard canonicalization ceiling**, but whether it can be accepted depends on the entire **1.75 MiB active Checkpoint** including State, Pending, Candidate, and receipt cache. Larger business outputs should be saved by the **Host's user-selected storage** before signing/returning a small reference in the existing Agent result. FlowKit never decides where files are saved or how business content expires.
- When a Host-authenticated, matching Agent receipt exceeds the inline bound, or a Coordinator transition would exceed the active Checkpoint budget, the Coordinator deterministically commits `errored(FK_I_CHECKPOINT_CAPACITY)` with original State intact, clears Pending/Candidate, appends a Journal entry, and then reports the error to the Host. It **does not** pretend to have accepted the receipt, does not manufacture `acceptedRevision`, and never emits a second Pending. Another Host may use `load` to inspect the durable terminal state. A different process racing this transition is resolved by optimistic CAS.
- An initial State that would overflow the active limit is rejected **before** Run creation; users should pass small values or Host-managed references.
- The soft budget applies to nonterminal transitions; a terminal error may use the remaining reserved capacity. Under the Store's new admission rules every newly written active Checkpoint has enough space for a bounded terminal error even after its Pending is removed.
- Malformed or oversized **untrusted** input rejected before Host authentication does not authorize an attacker to terminate another user's Run. The Host verifies provenance before invoking the private Coordinator.

## Receipt index — still FlowKit execution data

The default SQLite Store adds a narrowly scoped table `fk_i_receipt_index`:

`(namespace, run_id, request_id, node_id, attempt, digest, accepted_revision, entry_hash)`

This table is an **immutable idempotency index**, *not* a business result repository. A successful receipt CAS inserts the index row, updates the Checkpoint, and appends the audit Journal **inside one SQLite IMMEDIATE transaction**. `getReceipt(runId, requestId)` is namespace-scoped and verifies stored digest/row identity against the committed receipt Journal entry.

The Checkpoint retains at most the 16 most recent receipt summaries for low-cost inspection; all earlier accepted receipts remain accessible from the immutable SQLite index by `requestId` with their original `acceptedRevision` and digest. Existing Checkpoints whose full inline receipt history predates this feature are backfilled from their verified committed Journal records under the next receipt CAS before bounded compaction; no evidence is silently discarded.

Existing `CheckpointStore` adapters without the optional `getReceipt` capability retain legacy inline receipt handling. The reference SQLite adapter implements the new index and enforces the capacity contract. Other adapters must uphold equivalent atomicity and capacity/terminal behavior to claim S07 conformance.

## Non-goals and evidence

- No file I/O API or Context integration. The Host chooses where business outputs and temporary files live and when they expire.
- No automatic pruning of the SQLite receipt index, Journal or Program bytes. Run retention and schema migration are separate future work; an audit index must not disappear while an accepted Run may be queried.
- The M6 near-2MiB adversarial seed must now fail **before** creating an unacceptably large waiting Checkpoint, while the original valid 64-KiB Agent result proceeds from the prior safe Checkpoint. Oversized inline results must end in durable `errored`, preserving State, journal integrity and immutable Program identity.
- Stage 1 cannot claim S13 real Codex/Claude/Grok model/tool integration or mark M6 complete.

The change keeps the Program's routing authority, the 2 MiB hard integrity limit, namespace and artifact pins, and the existing Standard API untouched.
18 changes: 18 additions & 0 deletions docs/interactive.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,24 @@ try {
}
~~~

## S07 capacity and idempotency boundary

The reference SQLite Store enforces a 2 MiB hard Checkpoint bound and reserves
256 KiB for a durable terminal error. The capacity of any inline Agent result
depends on the **full** Checkpoint size; no additional arbitrary 128-KiB limit
is imposed. Larger business data belongs to the Host-selected storage
provider; Agent results should return only a small user-defined reference.

A signed matching inline result that exceeds the bound leads to a durable
`errored(FK_I_CHECKPOINT_CAPACITY)` transition **without an acceptedRevision**.
For accepted receipts the Store maintains a separate immutable SQLite
execution-only receipt index, while the Checkpoint retains only a bounded
recent-receipt cache. Older accepted request IDs retain their exact
acknowledgment across restarts, including after proof expiry.

See [the focused S07 storage contract](./interactive-s07-capacity.md). No
business-artifact persistence, file GC or Context module is added.

## Invariants and recovery

1. **Program is the routing authority.** The generated `advance(view,event)`
Expand Down
9 changes: 9 additions & 0 deletions src/interactive/canonical.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ import {types as nodeTypes} from 'node:util';
*/
export const MAX_CHECKPOINT_BYTES = 2 * 1024 * 1024;
export const MAX_JOURNAL_BYTES = 2 * 1024 * 1024;
// The reference Store admits nonterminal Checkpoints only below this budget:
// a failed Agent receipt must still leave enough bytes for a durable error exit.
export const CHECKPOINT_ERROR_RESERVE_BYTES = 256 * 1024;
export const MAX_ACTIVE_CHECKPOINT_BYTES = MAX_CHECKPOINT_BYTES - CHECKPOINT_ERROR_RESERVE_BYTES;
// There is no extra arbitrary per-Agent 128-KiB limit: the hard serialization
// bound stays 2 MiB. Admission is decided by the *whole* current Checkpoint
// against MAX_ACTIVE_CHECKPOINT_BYTES, not by payload size in isolation.
// Large business artifacts remain Host-owned.
export const MAX_INLINE_AGENT_RESULT_BYTES = MAX_CHECKPOINT_BYTES;

function validUnicode(value) {
for (let i = 0; i < value.length; i += 1) {
Expand Down
96 changes: 83 additions & 13 deletions src/interactive/coordinator.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {randomUUID} from 'node:crypto';
import {canonicalBytes, safeJSONSnapshot, sha256Bytes, assertSHA256} from './canonical.mjs';
import {MAX_INLINE_AGENT_RESULT_BYTES, canonicalBytes, safeJSONSnapshot, sha256Bytes, assertSHA256} from './canonical.mjs';
import {loadInteractiveProgram} from './load.mjs';

const FINISHED = new Set(['succeeded','failed','errored']);
// P0-A has at most 500 static nodes and 11 attempts per Agent.
// Do not cap legitimate 500-node workflows below their worst-case 5,500 receipts.
const MAX_RECEIPTS = 8192;
const MAX_INLINE_RECEIPT_CACHE = 16;
const MAX_REASONS = 32;
const MAX_REASON_LENGTH = 512;

Expand Down Expand Up @@ -185,11 +186,19 @@ function assertRuntime(program,runtime) {
}
}
function initialCheckpoint(namespace, runId, program, policyHash, state) {
let snapshot;
try { snapshot=copy(state); }
catch(error) {
if (error instanceof RangeError && /Canonical JSON exceeds maximum bytes/.test(error.message)) {
throw failure('FK_I_CHECKPOINT_CAPACITY', 'initial State exceeds Checkpoint hard limit');
}
throw error;
}
return {
schemaVersion:1,namespace,runId,revision:0,
artifactHash:program.artifactHash,policyHash,status:'running',
blockId:program.programTable.entryBlockId,
phase:'enter',state:copy(state),
phase:'enter',state:snapshot,
controlStack:[],activationSeq:0,receipts:[],
pending:null,prepared:null,candidate:null,terminal:null,
contextError:null,
Expand Down Expand Up @@ -257,6 +266,42 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt
throw error;
}
}
function failCapacity(cp) {
if (FINISHED.has(cp.status)) return {committed:false,checkpoint:cp};
const nodeId=cp.blockId.slice(0,cp.blockId.lastIndexOf('::'));
const next={
...cp,revision:cp.revision+1,status:'errored',
terminal:{status:'errored',nodeId,causeCode:'FK_I_CHECKPOINT_CAPACITY'},
pending:null,prepared:null,candidate:null,
pauseReason:null,
};
// This terminal transition may use the Store's reserved 256 KiB; it
// never drops State or accepted receipt identities.
return store.compareAndSwap({
runId:cp.runId,expectedRevision:cp.revision,nextCheckpoint:next,
transitionId:stableTransitionId(cp,'capacity-terminal'),
journalEntry:{kind:'capacity-terminal',causeCode:'FK_I_CHECKPOINT_CAPACITY',
from:cp.blockId},
});
}
function casOrCapacityExit(cp, args) {
try { return store.compareAndSwap(args); }
catch(error) {
if (error?.code!=='FK_I_CHECKPOINT_CAPACITY') throw error;
const terminal=failCapacity(cp);
return {...terminal,capacityExit:terminal.committed};
}
}
function oversizedInline(value) {
try { canonicalBytes(value,MAX_INLINE_AGENT_RESULT_BYTES);return false; }
catch(error) {
if (error instanceof RangeError && /Canonical JSON exceeds maximum bytes/.test(error.message)) {
return true;
}
throw error;
}
}

function stepCAS(cp, proposal, eventKind, extras={}) {
const next = {...cp,revision:cp.revision+1};
if (proposal.kind === 'raise-error') {
Expand Down Expand Up @@ -306,7 +351,7 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt
next.activationSeq=cp.activationSeq+1;
}
const transitionId=stableTransitionId(cp,eventKind + '/' + proposal.kind);
const write=store.compareAndSwap({
const write=casOrCapacityExit(cp,{
runId:cp.runId,expectedRevision:cp.revision,nextCheckpoint:next,transitionId,
journalEntry:{
kind:proposal.kind,event:eventKind,from:cp.blockId,
Expand Down Expand Up @@ -385,7 +430,7 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt
if (cp.status === 'paused') {
if (!['stage-budget','skill-temporary'].includes(cp.pauseReason)) return asPublicFrame(cp);
const unpause={...cp,status:'running',revision:cp.revision+1,pauseReason:null};
const committed=store.compareAndSwap({runId,expectedRevision:cp.revision,
const committed=casOrCapacityExit(cp,{runId,expectedRevision:cp.revision,
nextCheckpoint:unpause,transitionId:stableTransitionId(cp,'resume-budget'),
journalEntry:{kind:'resume-budget'}});
if (!committed.committed) continue;
Expand All @@ -404,7 +449,7 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt
if (['FK_R014','FK_I_TEMPORARY_SKILL'].includes(error?.code)) {
const paused={...cp,revision:cp.revision+1,status:'paused',
pauseReason:'skill-temporary'};
const result=store.compareAndSwap({runId,expectedRevision:cp.revision,
const result=casOrCapacityExit(cp,{runId,expectedRevision:cp.revision,
nextCheckpoint:paused,transitionId:stableTransitionId(cp,'pause-skill'),
journalEntry:{kind:'pause',reason:'skill-temporary'}});
if (!result.committed) continue;
Expand Down Expand Up @@ -451,7 +496,7 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt
}
const paused={...cp,revision:cp.revision+1,status:'paused',
pauseReason:'stage-budget'};
const result=store.compareAndSwap({runId,expectedRevision:cp.revision,
const result=casOrCapacityExit(cp,{runId,expectedRevision:cp.revision,
nextCheckpoint:paused,transitionId:stableTransitionId(cp,'pause-budget'),
journalEntry:{kind:'pause',reason:'stage-budget'}});
if (result.committed) return asPublicFrame(result.checkpoint);
Expand Down Expand Up @@ -496,15 +541,25 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt
throw failure('FK_I_INVALID_RECEIPT','Agent failure requires bounded {message:string}');
}
}
const submission=copy(kind==='agent-result'
// Size-only rejection must not require serializing an enormous receipt
// into the 2 MiB Checkpoint budget before we can mark a matched Run errored.
const rawSubmission=kind==='agent-result'
? {runId,requestId,nodeId,attempt,kind,result}
: {runId,requestId,nodeId,attempt,kind,failure:safeFailure});
const digest=fingerprint(submission);
: {runId,requestId,nodeId,attempt,kind,failure:safeFailure};
// Both the value AND its actual receipt envelope must be serializable
// under the protocol's 2 MiB bound. Otherwise snapshotting first would
// throw a raw RangeError while leaving an authenticated Pending waiting.
const inlineTooLarge=kind==='agent-result'
&& (oversizedInline(result) || oversizedInline(rawSubmission));
const submission=inlineTooLarge ? null : copy(rawSubmission);
const digest=inlineTooLarge ? null : fingerprint(submission);
for (;;) {
const cp=store.load(runId);
if (!cp) throw failure('FK_I_RUN_NOT_FOUND','run is not accessible');
const program=await loadPinned(cp);
const already=cp.receipts.find(r=>r.requestId===requestId);
const indexed=typeof store.getReceipt==='function';
const already=(indexed ? store.getReceipt(runId,requestId) : null)
?? cp.receipts.find(r=>r.requestId===requestId);
if (already) {
if (already.digest!==digest || already.nodeId!==nodeId || already.attempt!==attempt) {
throw failure('FK_I_RECEIPT_CONFLICT','Different receipt with accepted requestId');
Expand All @@ -522,13 +577,22 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt
|| pending.requestId!==requestId || pending.nodeId!==nodeId
|| pending.attempt!==attempt || frame.nodeId!==nodeId
|| frame.attempt!==attempt || block.nodeId!==nodeId
|| cp.receipts.length >= MAX_RECEIPTS) {
|| (!indexed && cp.receipts.length >= MAX_RECEIPTS)) {
throw failure('FK_I_STALE_RECEIPT','Wrong request, node, attempt, or exhausted ledger');
}
const accept=block.acceptTarget;
if (!block.legalTargets.includes(accept) || accept!==nodeId+'::validate') {
throw failure('FK_I_INVALID_TRANSITION','Awaiting receipt target is not certified');
}
// An authenticated Host may signal a business artifact by a small
// reference. FlowKit never stores oversized inline results or invents
// an external business-data provider. The durable failure is NOT an Ack.
if (inlineTooLarge) {
const terminal=failCapacity(cp);
if (!terminal.committed) continue;
throw failure('FK_I_CHECKPOINT_CAPACITY',
'inline result exceeds the configured execution budget; Run is errored');
}
const ledgerEntry={requestId,nodeId,attempt,digest,acceptedRevision:cp.revision+1};
const next={
...cp,revision:cp.revision+1,blockId:accept,phase:'validate',
Expand All @@ -537,12 +601,18 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt
submissionKind:kind,
...(kind==='agent-result'?{result:submission.result}
:{failure:submission.failure}),validated:false},
receipts:[...cp.receipts,ledgerEntry],
receipts:indexed
? [...cp.receipts.slice(-(MAX_INLINE_RECEIPT_CACHE-1)),ledgerEntry]
: [...cp.receipts,ledgerEntry],
};
const persisted=store.compareAndSwap({runId,expectedRevision:cp.revision,
const persisted=casOrCapacityExit(cp,{runId,expectedRevision:cp.revision,
nextCheckpoint:next,transitionId:stableTransitionId(cp,'receipt:'+requestId),
journalEntry:{kind:'receipt-accepted',nodeId,attempt,requestId,digest}});
if (!persisted.committed) continue;
if (persisted.capacityExit) {
throw failure('FK_I_CHECKPOINT_CAPACITY',
'receipt could not be accepted; Run is durably errored');
}
const current=await drive(runId);
return {acceptedRevision:ledgerEntry.acceptedRevision,...current,duplicate:false};
}
Expand Down
Loading