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
5 changes: 5 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ The compiler emits an IR bundle plus JSON Schemas and metadata. Provider credent

## Runtime semantics

The current branching source preview is documented in [BRANCHING.md](BRANCHING.md).
It uses lexical block environments, one shared execution budget and IR v2 for
control flow, while retaining v1 output for linear programs. See
[RFC 0002](rfcs/0002-typed-workflow-branching.md) for the pending design review.

### Execution states

`pending`, `running`, `waiting`, `succeeded`, `failed`, and `cancelled`.
Expand Down
68 changes: 68 additions & 0 deletions docs/BRANCHING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Workflow branching (source preview)

This checkout implements [RFC 0002](rfcs/0002-typed-workflow-branching.md), pending
public review in [#51](https://github.com/haseebahmed248/GuardStep/issues/51).
It is not available in `guardstep@0.1.0-alpha.1`. This is experimental, not a
production-readiness claim.

## Run the keyless example

From the repository root with Node.js 22 or newer:

```sh
npm ci
npm run build
./gs check examples/branching/decide.guard
./gs run examples/branching/decide.guard
./gs test examples/branching/decide.guard
```

The adjacent input and host files are discovered automatically. No model key,
Ollama server, account or paid service is needed: the model is a deterministic
fixture. The five cases cover empty-input failure, cached early return,
model-only execution, tool-then-model execution and permission denial.

The default output is `{ "text": "[mock model] [tool] hello" }`. Changing the
input text to `cached` returns without calling any adapter. `local` calls only
the mock model; empty text fails with `EMPTY`.

## Semantics

- `if condition { ... }`, optional `else { ... }`, nested branches and `else if`.
- Conditions must be pure booleans. Comparisons and existing list predicates
work; truthiness and calls inside conditions do not.
- `return value` succeeds with the declared output record. `fail CODE` fails
with a member of the workflow's failure enum. Both terminate the whole run.
- Every path must return or fail. An if without else may fall through to a
later return. Statements after an unconditional terminal are rejected.
- Variables are local to each arm. Arms can read outer bindings, but cannot
shadow them or export new bindings. Siblings may reuse local names.
- All arms are checked, even with literal conditions. No constant folding,
correlated-condition proof, union narrowing, nullable types or implicit joins.
- Capabilities and limits are declared once at workflow scope. Runtime grants
are checked only for selected tool calls. Counters, time and costs accumulate
across selected blocks; they do not reset on entry or exit.
- Static call limits use the maximum per path, including shared continuation,
rather than adding mutually exclusive arms. Model cost is accounted after a
completed call; the cap cannot undo a charge already incurred.

## Version and contract compatibility

Linear sources still emit IR v1 with unchanged IDs and generated contracts.
Branching or standalone fail emits IR v2. `compileSource` now returns
`WorkflowIr` (`WorkflowIrV1 | WorkflowIrV2`); callers requiring v1 must narrow
`schema_version`. The runtime and generator accept both versions. Unsupported
versions and v2 steps mislabeled as v1 are rejected before execution.

Use a compiler and runtime from the same checkout for this preview. The old npm
runtime predates v2 and is not a compatible consumer. IR is trusted compiler
output, not a safe format for executing arbitrary untrusted JSON or adapters.

Nested step IDs include the branch index and arm, such as
`Decide/branch:0/else/model:reply`. Generated model contracts visit every arm
with its lexical context and give branch-local models path-qualified names.
Existing event schema v1 is unchanged; there is no new branch event. Selected
effects retain their qualified step IDs, and terminal failures emit `run.failed`.

Run `npm run check` and `npm run check:generated` to verify both checked-in
examples and their typed host contracts.
4 changes: 3 additions & 1 deletion docs/DIAGNOSTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ The initial proof gates are:
| `GS2004` | effect attempted inside a pure expression |
| `GS2102` | invalid field access |
| `GS2105` | incompatible expression operands, including unsupported ordering |
| `GS2202` | workflow without a return |
| `GS2201` | non-boolean assertion or branch condition |
| `GS2202` | missing final-result path or incompatible return type |
| `GS2203` | unreachable statement after return, fail, or an exhaustive terminal branch |

When adding or intentionally changing a diagnostic, update the smallest relevant `.guard` fixture and its manifest entry in the same pull request.

Expand Down
126 changes: 126 additions & 0 deletions docs/rfcs/0002-typed-workflow-branching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# RFC 0002: typed workflow branching

Status: proposed; implementation is subject to review, not a released guarantee.
Related: #20. Public discussion: [#51](https://github.com/haseebahmed248/GuardStep/issues/51).
Public review period: September 7–14, 2026. The founding maintainer
will record the outcome and unresolved concerns here before merging the feature.

## Motivation

Document Q&A should be able to return early or fail when retrieval is empty,
without asking a host adapter to choose the workflow's control flow. Decisions
must preserve the same type checks, authority and budgets as linear steps.

## Syntax and supported subset

Complete minimal example:

```guard
enum Failure { DENIED DURATION COST }
record Input { text: String }
workflow Decide(input: Input) -> Input fails Failure {
capabilities {}
limits {
tool_calls <= 0
model_calls <= 0
duration <= 1s else DURATION
cost <= 0 USD else COST
}
if input.text == "" {
fail DENIED
} else {
return input
}
}
```

Conditions use existing pure boolean expressions. Braces are mandatory. Nested
`if`, optional `else`, and `else if` are supported. `fail CODE` terminates the
whole workflow with a value from its declared failure enum. An omitted else is
an empty fallthrough branch. Policy blocks remain workflow-level only.

Variables introduced in an arm are local to that arm and its nested blocks.
They cannot shadow visible variables. Sibling arms may use the same names, even
with different types; neither binding exists after the branch. Every return
must match the declared output record. No implicit branch-result assignments.

There is **no union/optional type narrowing in this slice**: those types do not
exist in the executable language. An enum equality test does not change a
record's fields or make a String nullable. Narrowing needs a separate type-system
proposal; this implementation must not pretend it is available.

## Final paths and diagnostics

All syntactic paths must end in `return` or `fail`. Both arms are checked even
for literal conditions; no constant folding or correlated-condition proof.

- `if true { return input }` without a final return/else is incomplete (GS2202).
- A non-boolean condition is rejected (GS2201).
- A statement after return/fail or an if whose arms both terminate is unreachable
(GS2203). A statement after an if with a fallthrough arm is reachable.
- Returning a different record in one arm is incompatible (GS2202).
- Referencing an arm-local value after its block is unknown (GS2101).
- An undeclared failure is rejected (GS2003), even in an unselected arm.
- Effects inside conditions remain forbidden (GS2004).

## IR and compatibility

Linear programs keep their existing version 1 JSON and step IDs. Programs using
`if` or standalone `fail` emit version 2. Version 2 adds a `branch` node with
`condition`, `then`, `else`, source range and step ID, plus a `fail` node with its
error code. No closures, evaluated source, adapter calls or executable JS in IR.

Branch and v2 assertion IDs use deterministic statement indices within their enclosing block;
arm paths qualify all nested IDs, so sibling assignment names cannot collide.
The compiler API returns `WorkflowIr` (v1 | v2); explicit v1 consumers must check
the version. The updated runtime accepts v1/v2 and rejects unsupported versions
before any effects. Older runtimes must not be used with v2; the current npm
alpha's old runtime is not a v2 consumer. Execution-event schema stays v1;
selected effects carry arm-qualified IDs, and fail uses `run.failed`.

Generated domain/workflow/tool contracts stay unchanged for linear inputs. Model
contracts include calls in every arm, inferred in their lexical environment;
branch-local model symbols include the branch path. Unchosen arms do not invoke
their host or model adapter.

## Authority and budgets

All arms require declared capabilities statically. Runtime grants are checked
only when a tool is actually reached. Runtime counters, cost and absolute
deadline are shared by all selected blocks; entering a branch never resets them.

Static call limits use a conservative maximum over paths, not a sum of mutually
exclusive arms. A terminating arm does not also pay for later continuation
steps. Sequential branches can accumulate calls. Analysis summarizes maximum
and fallthrough counts separately in linear time, without enumerating paths.

Budget enforcement runs at control-flow boundaries and before terminal results.
An already exhausted deadline cannot dispatch a new effect, including at exact
deadline equality. The deadline helper rechecks at dispatch to cover time spent
evaluating arguments and scheduled microtasks. Cost may exceed the cap during
one already-started model call; its measured charge prevents later effects.
This is not a prepaid billing guarantee or a sandbox for untrusted adapters.

## Alternatives and open concerns

- Host-only decisions hide control flow from checking and budget analysis.
- A match expression would need exhaustiveness and result-type rules beyond
this small subset; a future RFC can add it.
- Exporting branch-local values requires a join/definite-assignment design.
- Keeping IR v1 would let old consumers misinterpret new node kinds.
- Dedicated branch trace events are deferred; assess whether arm-qualified
effect IDs plus terminal events are enough for consumers.
- No loops, retries, try/catch, unions, literal narrowing or branch result values.

## Conformance and rollout

Test true/false/nested/else-if/failure paths; early returns; incomplete and
unreachable paths; incompatible results and local scopes; worst-path budgets;
denied grants; accumulated time/cost/call limits; deterministic roundtripped IR;
generated model contracts and CLI check/run/test. Retain all linear fixtures.
Ship source documentation and a runnable keyless example. Do not publish a new
npm version or merge this proposal as accepted until the review outcome is recorded.

## Outcome

Pending public review. No acceptance decision has been recorded.
133 changes: 133 additions & 0 deletions examples/branching/decide.generated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Generated by GuardStep. Do not edit manually.
// Generator: GuardStep 0.1.0-alpha.1
// Source: decide.guard
// Source SHA-256: sha256:9ee78af661506b1138986bf6c62c64edd62b3fda61136399ab39bfb9c3b73c04

import type { Pricing as __GuardStepPricing } from "guardstep/runtime";

export type Failure =
| "EMPTY"
| "DENIED"
| "TIMEOUT"
| "ERROR"
| "INVALID"
| "DURATION"
| "COST";

export interface Message {
readonly "text": string;
}

export interface EchoToolInvocation {
readonly runId: string;
readonly stepId: string;
readonly tool: "echo";
readonly arguments: {
readonly "text": string;
};
readonly signal: AbortSignal;
}

export type EchoToolResult =
| {
readonly status: "succeeded";
readonly value: Message;
readonly elapsedMs: number;
readonly eventData?: Readonly<Record<string, unknown>>;
}
| {
readonly status: "failed";
readonly kind: "timeout" | "error";
readonly code?: string;
readonly elapsedMs: number;
};

export interface DecideBranch0ElseBranch0ElseBranch0ThenReplyModelInvocation {
readonly runId: string;
readonly stepId: "Decide/branch:0/else/branch:0/else/branch:0/then/model:reply";
readonly profile: "mock";
readonly instructions: string;
readonly context: {
readonly "text": string;
};
readonly outputSchema: Readonly<Record<string, unknown>>;
readonly signal: AbortSignal;
}

export type DecideBranch0ElseBranch0ElseBranch0ThenReplyModelResult =
| {
readonly status: "succeeded";
readonly value: Message;
readonly usage: {
readonly input_tokens: number;
readonly output_tokens: number;
};
readonly elapsedMs: number;
}
| {
readonly status: "failed";
readonly code: string;
readonly elapsedMs: number;
};

export interface DecideBranch0ElseBranch0ElseBranch0ElseReplyModelInvocation {
readonly runId: string;
readonly stepId: "Decide/branch:0/else/branch:0/else/branch:0/else/model:reply";
readonly profile: "mock";
readonly instructions: string;
readonly context: {
readonly "text": string;
};
readonly outputSchema: Readonly<Record<string, unknown>>;
readonly signal: AbortSignal;
}

export type DecideBranch0ElseBranch0ElseBranch0ElseReplyModelResult =
| {
readonly status: "succeeded";
readonly value: Message;
readonly usage: {
readonly input_tokens: number;
readonly output_tokens: number;
};
readonly elapsedMs: number;
}
| {
readonly status: "failed";
readonly code: string;
readonly elapsedMs: number;
};

export type GuardStepToolInvocation = EchoToolInvocation;
export type GuardStepToolResult = EchoToolResult;
export type GuardStepModelInvocation = DecideBranch0ElseBranch0ElseBranch0ThenReplyModelInvocation | DecideBranch0ElseBranch0ElseBranch0ElseReplyModelInvocation;
export type GuardStepModelResult = DecideBranch0ElseBranch0ElseBranch0ThenReplyModelResult | DecideBranch0ElseBranch0ElseBranch0ElseReplyModelResult;

export interface GuardStepWorkflowContracts {
readonly "Decide": {
readonly input: Message;
readonly output: Message;
readonly failure: Failure;
};
}

export type GuardStepWorkflowName = keyof GuardStepWorkflowContracts;
export type GuardStepWorkflowInput<Name extends GuardStepWorkflowName> = GuardStepWorkflowContracts[Name]["input"];
export type GuardStepWorkflowOutput<Name extends GuardStepWorkflowName> = GuardStepWorkflowContracts[Name]["output"];
export type GuardStepWorkflowFailure<Name extends GuardStepWorkflowName> = GuardStepWorkflowContracts[Name]["failure"];

export type GuardStepCapability =
| "echo";

export interface GuardStepHost {
readonly schemaVersion: 1;
readonly workflow?: GuardStepWorkflowName;
readonly grantedCapabilities: readonly GuardStepCapability[];
readonly pricing: __GuardStepPricing;
readonly tools: {
invoke(request: GuardStepToolInvocation): Promise<GuardStepToolResult>;
};
readonly model: {
generate(request: GuardStepModelInvocation): Promise<GuardStepModelResult>;
};
}
39 changes: 39 additions & 0 deletions examples/branching/decide.guard
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
enum Failure { EMPTY DENIED TIMEOUT ERROR INVALID DURATION COST }
record Message { text: String }
tool echo(text: String) -> Message

workflow Decide(input: Message) -> Message fails Failure {
capabilities { echo else DENIED }
limits {
tool_calls <= 1
model_calls <= 1
duration <= 1s else DURATION
cost <= 0 USD else COST
}

if input.text == "" {
fail EMPTY
} else if input.text == "cached" {
return input
} else if input.text == "local" {
reply = generate Message using model("mock") {
instructions: "Echo the supplied text."
context: { text: input.text }
}
on error => fail ERROR
on invalid => fail INVALID
return reply
} else {
original = call echo(text: input.text)
on timeout => fail TIMEOUT
on error => fail ERROR
on invalid => fail INVALID
reply = generate Message using model("mock") {
instructions: "Echo the supplied text."
context: { text: original.text }
}
on error => fail ERROR
on invalid => fail INVALID
return reply
}
}
Loading