Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 10 additions & 1 deletion .github/workflows/cloud-runtime-artifact.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,17 @@ jobs:
working-directory: kernel
run: cargo test --workspace

- name: Build authoring surface
working-directory: surface
run: |
bun install --frozen-lockfile --ignore-scripts
bun run build

# --ignore-scripts because the surface is already built above; without it
# npm runs the file: dependency's prepare before its own devDependencies
# exist. The SDK's own build is the next step, so nothing is skipped.
- name: Install SDK dependencies
run: npm ci --prefix sdk
run: npm ci --prefix sdk --ignore-scripts

- name: Test SDK and type-level authoring contracts
working-directory: sdk
Expand Down
43 changes: 43 additions & 0 deletions .github/workflows/surface-package.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Relayflow v2 surface package

on:
workflow_dispatch:
pull_request:
paths:
- ".github/workflows/surface-package.yml"
- "scripts/surface-package-gate.sh"
- "surface/**"
- "regressions/**"
- "sdk/**"
push:
branches:
- main
paths:
- ".github/workflows/surface-package.yml"
- "scripts/surface-package-gate.sh"
- "surface/**"
- "regressions/**"
- "sdk/**"

permissions:
contents: read

jobs:
packed-consumer:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}

- uses: actions/setup-node@v4
with:
node-version: "22"

- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.4.0"

- name: Test source, regressions, and packed consumers
run: bash scripts/surface-package-gate.sh
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Nothing in this repo may contradict it; changing it is a human decision.
```
kernel/ relayflowd — Rust. Journal, scheduler, leases, timers, streams. One binary.
sdk/ TypeScript-first authoring SDK. Compiles specs; speaks the journal protocol.
surface/ @relayflows/surface — the TypeScript flow-authoring contract.
workflows/ The gates. Each gate is a relayflow; the build is orchestrated by relayflows.
docs/ RFC-0001 and design docs.
charter/ The Relayflow Lead.
Expand Down
59 changes: 57 additions & 2 deletions docs/SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@ export default flow("chief", {
.on(slack.mention("#exec"), async (f, event) => { // gate 2 — trigger = entry condition
const intent = await f.llm`Extract the work request, if any: ${event.text}`
.gate(isActionable);
if (!intent) return f.done("no_work");
if (!intent) return f.done("success"); // no work is an outcome; execution succeeded

const plan = await f.agent("planner", {
task: `Research and plan: ${intent}`,
workspace: "acme/api: readonly", // compiles to relayauth path scopes
});

const ok = await f.human(`Ship this?\n${plan.summary}`, { to: "khaliq" });
if (!ok) return f.done("declined");
if (!ok) return f.done("canceled");

const pr = await f.dispatch("garden/implement", plan); // gate 3 — child flow
await f.slack.reply(event, `Shipped: ${pr.url}`);
Expand Down Expand Up @@ -221,6 +221,61 @@ The authoring surface deliberately narrows `steps: []`: `flows check` refuses
it as `invalid_spec`, while the kernel accepts it. This is a chosen
authoring-time narrowing, not a kernel guarantee.

### The authored operation lifecycle

An authored TypeScript body reaches `done()` only if every step it created was
actually consumed on the continuation that got there, and nothing derived from a
step was still running or had failed unobserved. Three rules, in the author's
vocabulary:

1. **Await every step.** Creating `f.run(...)` and never awaiting it is
`unawaited_step`. Constructing steps and awaiting them later is fine —
`const steps = [f.run(a), f.run(b)]; for (const s of steps) await s;` is
ordinary, supported authoring, and so is `Promise.resolve`, `Promise.all`,
`Promise.allSettled`, `Promise.any` and `Promise.race` over authored steps.
A manual `.then(...)` callback is not an await and is refused; callback
source text is never treated as proof of anything.
2. **A step's failure is yours whether or not you catch it.** A root failure is
recorded before author code can reach the operation, so a `catch` cannot hide
it. At this gate the executor only lowers `done("success")`, so there is no
expressible recovery from a failed step yet.
3. **Finish your derived work before `done()`.** If a handler chained onto a step
is still in flight when the body returns, the run is refused with
`unsettled_derived_work` rather than recorded as a success nobody can prove.
Awaited derived work is always settled by then; only fire-and-forget work is
caught by this. If you start something after a step, await it before `done()`.

**Documented limit.** Work that does not yet *exist* when the body returns
cannot be seen. `setTimeout(() => { p.then(handler).catch(ignore); })` schedules
a derived chain to begin after completion, and the gate will not observe it.
This is the boundary of the contract, not an oversight: the flow has already
finished when that promise is created. Do not use a timer to smuggle
post-completion work into a run.

**Disclosure: this package replaces `Promise.all` while a flow is open.** The
lifecycle installs its own `Promise.all` on the global `Promise` for the
duration of any authored flow execution, and restores the original when the last
concurrent flow closes.

- *Why:* a combinator's aggregate has no runtime edge back to its non-final
members, so `await Promise.all([a, b])` cannot otherwise be proven to have
consumed `a`. The alternatives all infer group membership from the callbacks
the combinator passes each element, which is exactly the callback-identity
inference this contract exists to refuse.
- *Scope:* process-wide, for the lifetime of an authored flow execution. Any code
in the process — including yours and your dependencies' — sees the replacement
during that window.
- *Behaviour:* the replacement delegates to the intrinsic and is specified to
behave identically. A non-iterable argument is handed straight through, so
`Promise.all(5)` and `Promise.all(null)` return the same rejected promises the
intrinsic returns; `name` and `length` match. If `Promise.all` has already been
replaced by something else, the flow refuses to start rather than fighting over
the intrinsic.

If a process-wide intrinsic replacement is unacceptable in your deployment, do
not run authored TypeScript bodies in that process; the declarative YAML path
does not install it.

## 3. Plugins: the kernel is closed, the surface is open

The herdr model: first-party helpers are just plugins that ship in the box; the community brings the rest.
Expand Down
88 changes: 88 additions & 0 deletions ops/pr134-lifecycle-repair-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# PR #134 authored lifecycle repair evidence

This evidence was captured in `flows-132-surface-wt` while repairing the three
P1 lifecycle escapes reported against `5f2c0b9a`. Review reports and gate
scripts were not edited.

## Red-first reproduction

The exact native-resolver, ignored-assimilation, ignored-combinator,
swallowed-callback, and forged-source cases were first committed as
`b89eef8 test(surface): reproduce authored lifecycle escapes`.

```text
$ ./node_modules/.bin/vitest run tests/authored-flow-operation.test.ts --reporter=verbose --maxWorkers=1 --minWorkers=1
Test Files 1 failed (1)
Tests 15 failed (15)
```

All five cases failed for each of `run`, `llm`, and `agent` because the old
implementation resolved instead of returning the expected typed rejection.

## Focused source and type evidence

```text
$ ./node_modules/.bin/tsc --noEmit && ./node_modules/.bin/vitest run tests/authored-flow-operation.test.ts tests/authored-flow-lifecycle-executor.test.ts tests/authored-flow.test.ts --reporter=verbose --maxWorkers=1 --minWorkers=1
Test Files 3 passed (3)
Tests 38 passed (38)
```

The focused cases prove typed refusal for all three primitive families, no
terminal `complete-*` start through the journal executor, retained root and
nested callback failures, isolation of concurrent lifecycle scopes, and green
direct `await`, `Promise.resolve`, and direct/wrapped `Promise.all` paths.

```text
$ (cd surface && bun run build && bun run test && bun run typecheck:regressions)
Test Files 1 passed (1)
Tests 6 passed (6)
```

## Packed artifact evidence

`bash scripts/surface-package-gate.sh` completed its surface build, six tests,
regression typecheck, and tarball creation, then the environment's
`npm ci --prefix sdk --ignore-scripts` produced no output for more than 60
seconds and was interrupted. The artifacts were therefore packed directly
from the already-installed, typechecked worktree and exercised in a clean
temporary consumer:

```text
7a9d98c9fef8ec34f7562f1efcc72e6e6b3019ed relayflows-sdk-0.1.0.tgz
da4e66ca062eb34e06d7adccb1a2a4c358f42275 relayflows-surface-0.1.0.tgz
PACKED_EXECUTOR_REFUSAL name=packed-native-resolver code=unawaited_step terminal=absent
PACKED_EXECUTOR_REFUSAL name=packed-ignored-resolve code=unawaited_step terminal=absent
PACKED_EXECUTOR_REFUSAL name=packed-ignored-all code=unawaited_step terminal=absent
PACKED_EXECUTOR_REFUSAL name=packed-nested-callback code=operation_callback_failed terminal=absent
PACKED_EXECUTOR_REFUSAL name=packed-forged-source code=unawaited_step terminal=absent
PACKED_EXECUTOR_GREEN direct-await=pass promise-resolve=pass promise-all=pass
PACKED_PRIMITIVE_REFUSAL verb=run native-resolver=refused ignored-resolve=refused ignored-all=refused
PACKED_PRIMITIVE_REFUSAL verb=llm native-resolver=refused ignored-resolve=refused ignored-all=refused
PACKED_PRIMITIVE_REFUSAL verb=agent native-resolver=refused ignored-resolve=refused ignored-all=refused
```

## Live daemon evidence

The built SDK executor was exercised against
`/Users/khaliqgant/.relayflows-toolchain/target/1914866954/debug/relayflowd`.

```text
LIVE_EXECUTOR_REFUSAL name=live-native-resolver code=unawaited_step terminal=absent
LIVE_EXECUTOR_REFUSAL name=live-ignored-resolve code=unawaited_step terminal=absent
LIVE_EXECUTOR_REFUSAL name=live-ignored-all code=unawaited_step terminal=absent
LIVE_EXECUTOR_REFUSAL name=live-nested-callback code=operation_callback_failed terminal=absent
LIVE_EXECUTOR_REFUSAL name=live-forged-source code=unawaited_step terminal=absent
LIVE_EXECUTOR_GREEN name=live-supported-awaits completion=success steps=5
```

## Full SDK regression evidence

```text
$ RELAYFLOWD_BIN=/Users/khaliqgant/.relayflows-toolchain/target/1914866954/debug/relayflowd RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 ./node_modules/.bin/vitest run --reporter=dot --maxWorkers=1 --minWorkers=1
Test Files 20 passed (20)
Tests 275 passed (275)
Duration 75.27s
```

The run reported `SKIPPED_UNACTIONABLE=0`, executed the live Claude analyzer
round trip, and completed the real-daemon crash/resume case.
57 changes: 57 additions & 0 deletions ops/probes/pr134-repair-0903/aggregate-membership.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// F1 + F2: an aggregate is derived from EVERY member, but the runtime supplies a
// resolutionCause edge to only ONE — whichever member resolved it. Every row here
// has AT LEAST TWO members and deliberately varies which one resolves the
// aggregate, because a single-member aggregate cannot exhibit the defect at all.
import { flow, runFlow, verdict } from './harness.mjs';

const ticks = (n) => async () => { for (let i = 0; i < n; i++) await null; };
const slower = (ms = 15) => new Promise((r) => setTimeout(() => r('unrelated'), ms));
const faster = () => Promise.resolve('unrelated-fast');

// --- F1: derived failure hidden behind an aggregate an unrelated member resolved
const escape = (name, build) => flow(name, async (f) => {
const step = f.run('true');
const agg = build(step);
const derived = agg.then(async () => {
await ticks(10)();
throw new Error('work derived from the authored step failed');
});
derived.catch(() => undefined); // handled and forgotten
await agg;
await step;
f.done('success');
});

const escapes = {
'allSettled([step, slowerUnrelated]) resolved by the UNRELATED member': (s) => Promise.allSettled([s, slower()]),
'allSettled([slowerUnrelated, step]) resolved by the UNRELATED member': (s) => Promise.allSettled([slower(), s]),
'race([fastUnrelated, step]) resolved by the UNRELATED member': (s) => Promise.race([faster(), s]),
'any([fastUnrelated, step]) resolved by the UNRELATED member': (s) => Promise.any([faster(), s]),
'all([step, slowerUnrelated]) resolved by the UNRELATED member': (s) => Promise.all([s, slower()]),
'allSettled([step, fastUnrelated]) resolved by the STEP (control) ': (s) => Promise.allSettled([s, faster()]),
};
console.log('=== F1 deferred derived failure behind a multi-member aggregate [ALL MUST REFUSE] ===');
for (const [label, build] of Object.entries(escapes)) {
const r = await runFlow(escape(`esc-${label.slice(0, 12)}`, build));
const ok = r.error !== undefined;
console.log(` ${ok ? 'OK ' : 'FAIL'} | ${label} | ${verdict(r)}`);
}

// --- F2: correct, documented authoring that must NOT be refused
const legit = {
'await Promise.allSettled([a, b]) ': async (f) => { await Promise.allSettled([f.run('true'), f.run('true')]); },
'await Promise.allSettled over 5 steps ': async (f) => { await Promise.allSettled([f.run('true'), f.run('true'), f.run('true'), f.run('true'), f.run('true')]); },
'await Promise.all([a, b, c]) ': async (f) => { await Promise.all([f.run('true'), f.run('true'), f.run('true')]); },
'await Promise.race([a, b]) ': async (f) => { await Promise.race([f.run('true'), f.run('true')]); },
'await Promise.any([a, b]) ': async (f) => { await Promise.any([f.run('true'), f.run('true')]); },
'await step then reuse it in a later race ': async (f) => { const s = f.run('true'); await s; await Promise.race([Promise.resolve('x'), s]); },
'for await (const v of [a, b]) ': async (f) => { for await (const v of [f.run('true'), f.run('true')]) void v; },
'for await (const v of [a]) ': async (f) => { for await (const v of [f.run('true')]) void v; },
'allSettled mixing a step and an unrelated ': async (f) => { await Promise.allSettled([f.run('true'), slower(5)]); },
};
console.log('=== F2 correct, documented authoring [ALL MUST PASS] ===');
for (const [label, body] of Object.entries(legit)) {
const r = await runFlow(flow(`ok-${label.slice(0, 10)}`, async (f) => { await body(f); f.done('success'); }));
const ok = r.error === undefined && r.terminal;
console.log(` ${ok ? 'OK ' : 'FAIL'} | ${label} | ${verdict(r)}${r.error ? ' :: ' + r.error.message.slice(0, 90) : ''}`);
}
37 changes: 37 additions & 0 deletions ops/probes/pr134-repair-0903/combinators.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Does the in-flight rule cover every combinator, or only the registered one?
// Promise.allSettled / any / race are NOT intercepted, so their aggregate is not
// downstream of any member by `trigger`; it inherits attribution only from the
// context that resolves it.
import { flow, runFlow, verdict } from './harness.mjs';
const ticks = (n) => async () => { for (let i = 0; i < n; i++) await null; };

const exploit = (name, consume) => flow(name, async (f) => {
const consumed = consume(f.run('true'));
const derived = consumed.then(async () => {
await ticks(10)();
throw new Error(`derived post-processing failed (${name})`);
});
derived.catch(() => undefined);
await consumed;
f.done('success');
});
const legit = (name, consume) => flow(name, async (f) => { await consume(f.run('true')); f.done('success'); });
const ignored = (name, consume) => flow(name, async (f) => {
void consume(f.run('true'));
await new Promise((r) => setTimeout(r, 60));
f.done('success');
});

const shapes = {
'Promise.allSettled': (s) => Promise.allSettled([s]),
'Promise.any ': (s) => Promise.any([s]),
'Promise.race ': (s) => Promise.race([s]),
'Promise.all ': (s) => Promise.all([s]),
'Promise.resolve ': (s) => Promise.resolve(s),
};
for (const [label, consume] of Object.entries(shapes)) {
const l = verdict(await runFlow(legit(`legit-${label.trim()}`, consume)));
const x = verdict(await runFlow(exploit(`exploit-${label.trim()}`, consume)));
const g = verdict(await runFlow(ignored(`ignored-${label.trim()}`, consume)));
console.log(`${label} awaited=${l.padEnd(22)} deferred-derived-failure=${x.padEnd(26)} ignored=${g}`);
}
28 changes: 28 additions & 0 deletions ops/probes/pr134-repair-0903/gate-cost.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// P1-B: cost of the completion gate against ordinary in-flow promise churn.
// Usage: node gate-cost.mjs [distDir] [awaits] (distDir defaults to "dist")
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const DIST = process.argv[2] ?? 'dist';
const N = Number(process.argv[3] ?? 30000);
const { AuthoredFlowOperation, verifyAuthoredOperations } = await import(`${REPO}/sdk/${DIST}/authored-flow-operation.js`);
const { AuthoredFlowLifecycle } = await import(`${REPO}/sdk/${DIST}/authored-flow-lifecycle.js`);

const lc = new AuthoredFlowLifecycle();
const ops = [];
const mk = () => {
const o = new AuthoredFlowOperation(`run-${ops.length + 1}`, 'run', () => undefined, async () => 'v', lc);
ops.push(o); return o;
};
let t0 = Date.now();
await lc.runBody(async () => {
await mk().step;
for (let i = 0; i < N; i++) await Promise.resolve(i); // ordinary in-flow async work
lc.markCompletion();
});
const bodyMs = Date.now() - t0;
t0 = Date.now();
let err = null;
try { await verifyAuthoredOperations('gate-cost', ops, lc); } catch (e) { err = e; }
console.log(`${DIST} awaits=${N} body=${bodyMs}ms verifyAuthoredOperations=${Date.now() - t0}ms verdict=${err ? err.code : 'PASSED'}`);
lc.close();
Loading
Loading