Skip to content

feat: type-check expressions, and allow keyword field names - #29

Merged
myzie merged 9 commits into
mainfrom
typecheck
Oct 28, 2026
Merged

myzie merged 9 commits into
mainfrom
typecheck

Conversation

@myzie

@myzie myzie commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

What changes for the user

Program.Check(env) finds an expression's mistakes before it runs. You describe the env as a map[string]*expr.Type. Check returns the result type and a *TypeError that lists every problem. Field names can now also be Go keywords, such as event.type.

See it

go run ./examples/type_checking checks expressions against a typed env. Part of its output:

issue.titel
    `issue.titel`: no field "titel" in {kind: "bug" | "feature" | "question", labels: list<string>, score: number, title: string} (did you mean "title"?)
issue.kind == "bugfix"
    `issue.kind == "bugfix"`: the value is one of "bug" | "feature" | "question", never "bugfix"
review.verdict
    `review`: may be nil here; use ?. or try(...)
issue.score + issue.title
    `issue.score + issue.title`: cannot add number and string
split(issue.title, 1)
    `1`: split: argument 2 must be string, not number

Why

Hosts often store expressions: in config, in rules, or in workflow definitions. Today a mistake in a stored expression shows up only when some input reaches it. That can be days after someone saved it. Check reports the mistake when the expression is saved. The workflow library's typed definitions (deepnoodle-ai/workflow#42) use it to check every condition and template in a definition at load time.

GitHub webhook payloads use type, range and default as field names. Go's parser rejects x.type, so these expressions did not compile before this PR.

What Check reports

  • Names that are not in env, a binding, or the registered functions.
  • Fields that a closed object does not have, with "did you mean".
  • Operations on the wrong kind of value, such as adding a number and a string.
  • Builtin calls with the wrong number or kind of arguments.
  • Reads of values that may be nil, and of keys that may be absent.
  • == and != that can never be true, such as a string outside an enum.

Template.Check(env) checks every ${...} in a template. WithFuncType declares the type of a function that a host registers.

Decisions to check

  • Check follows Run, not an ideal type system. && and || return an operand, so s || 0 types as the union 0 | string. A nil check narrows by the text of the expression. Reason: a check that disagrees with Run rejects good expressions or passes bad ones. Rejected: type && and || as bool.
  • Type is a struct with exported fields. Hosts build types directly, for example from a JSON Schema. A union is a KindAny type with Variants. Reason: hosts can build and inspect types without a builder API. Cost: the struct layout becomes public API at v1.3.0. Rejected: an opaque type with constructors only.
  • Any turns checking off. A nil *Type in env or in Fields also means Any. Reason: a host can start with a partly typed env and add types later. Rejected: require a type for every name.
  • A key that may be absent needs ?. or ?[. This applies to an unlisted field of an open object with a typed Elem. It also applies to an index by a non-literal string, such as scores[name], on any object. Reason: Run fails on a missing key. Rejected: accept the read and let Run fail.
  • Keyword field names use a source rewrite. Before parsing, x.type becomes x.__expr_kw_type. After parsing, the name changes back on the syntax tree. Reason: expr keeps using go/parser. Rejected: a parser of our own.

How to verify

go test ./...
go run ./examples/type_checking

TestCheckMatchesRun holds about 50 expressions that Check rejects, and Run must fail on each of them. It also holds about 20 that Check accepts, and Run must succeed on each of them. TestGuide_TypeChecking and TestReadme_TypeChecking test every claim in the new guide and in the README.

Start reading at docs/guides/type-checking.md, then types.go, then check.go.

Risk and rollout

  • The change adds API. Existing programs that do not call Check behave as before.
  • Expressions with keyword field names failed to compile before. Now they compile.
  • Identifiers beginning with __expr_ or __try_ are reserved, as they were on main. A field literally named __expr_kw_type now reads the field type. On main, x.__expr_map__ already read the field map. The spec now states the rule.
  • Compile no longer precomputes == and != between two literals. The result is the same. The change lets Check see comparisons such as "a" == 1.
  • After merge, tag v1.3.0. Then move workflow#42 from the pseudo-version to that tag.

Not in this PR

  • A list index that is out of range. Check cannot see values, so Run still reports this.
  • Types for registered functions without WithFuncType. These return Any.
Reviews
  • An independent review tried to make expressions pass Check and then fail in Run. It found 10 cases in the workflow library. Each case is now a regression test here or in workflow#42.
  • The review bot found S1 to S5 and N1. All are fixed in 2fb599e and ae64ca3.
  • A second review found B1 to B7 and M1 to M3. All are fixed in cbb2e25, and each case has a test in TestCheckMatchesRun. A follow-up review confirmed the fixes. Its one nit is fixed in e636a20.
  • The documentation is in 9aae17d: a guide, an example program, a README section, and notes in the spec and llms.txt.
  • No acceptance tester ran. The author ran every example program, and the workflow#42 test suite with Postgres, against this branch.

🤖 Generated with Claude Code

myzie and others added 3 commits October 28, 2026 10:01
Program.Check(env) type-checks an expression against the types of its
environment and returns its result type. It reports unknown names and
fields, reads of maybe-nil values without ?. or try, wrong-kind
operations, and comparisons that are never true, including against an
enum. Template.Check checks every segment. WithFuncType types registered
functions.

Field names may now be Go keywords (event.type, x?.range), which JSON
payloads use freely.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- && and || type as the join of their operands, as Run returns operands.
- Nil checks narrow (x != nil && x.a, x == nil || x.a, if(x != nil, x.a, b)).
- Builtins that fail on nil (upper, round, split, ...) report nil arguments.
- try needs 2 arguments and if needs 3, as Run requires.
- == and != on lists and objects are reported.
- Unlisted fields of an open object are maybe-absent: read them with ?.
- Join treats a missing element type as Any.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Join of different known kinds now keeps the variants instead of collapsing
to Any, so (s || 0) + 1 and a.items && len(a.items) > 0 used as a boolean
are reported rather than unchecked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@mobiusops mobiusops Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment: Two Should-fix items need to land before merge: S1 can make Check panic on a nil field type, and S2 lets a x != nil check leak into a binding that shadows x.

This is the checker that deepnoodle-ai/workflow#42 depends on, so the public Type surface should be settled before that merges. The spec section matches the code except for S5.

Should fix

S1. Nil field types panic in Check (check.go:409)
A field typed nil, such as ObjectOf(map[string]*Type{"items": nil}), flows into code that reads .Kind. first(obj.items) panics in elemFunc (check.go:717), sameFunc panics the same way (check.go:723), and obj[k] panics in index (check.go:462) when k is a nil-typed field. ident already maps a nil env entry to Any, so fields should match. Fix: in field, return orAny(f) in the case ok: branch so a nil type never leaves field.

S2. A nil check leaks into a binding that shadows the name (check.go:148, check.go:600)
narrowed is keyed by source text and is never cleared when any, map, and the other forms rebind a name. With x: {a: number}? and xs: list<{a: number}?>, x != nil && any(xs, x, x.a > 1) passes. Inside any, x is a list element that can be nil, but applyNarrowing still drops its ?. Fix: in iterating, remove narrowed entries equal to bind or starting with bind + "." while the body is checked, then restore them.

S3. A host function named like a builtin gets the builtin's type (check.go:540)
A registered function takes the builtin's type whenever the name matches, even when WithFunctions replaced the builtin. WithFunctions({"len": myLen}), where myLen takes numbers, makes len(5) fail with "len of number", and Run accepts it. Fix: skip builtinTypes for any name a WithFunctions option replaced. This can wait for a follow-up.

S4. x[i] on a value that is always nil passes (check.go:446)
field reports "the value is always nil" (check.go:394), but index returns Null() with no report. nil[0] passes Check, and indexValue fails it at run time with "cannot index nil" (program.go:1358). Fix: in index, report the same way field does when !optional && c.lenient == 0. This can wait for a follow-up.

S5. Two single values are never compared (check.go:379)
The test needs len(set.Enum) > 1, so "a" == "b" passes, and so does EnumOf("a") == "b". The spec says literals are single-value enums and that a literal outside the other side's enum is reported. Fix: drop the > 1 test. This can wait for a follow-up.

Nits

  • N1. restoreKeywordFields strips __expr_kw_ from every selector, so a user's x.__expr_kw_foo now reads foo. Limit the strip to names the rewrite created, or accept it and leave a comment. (engine.go:392)

Questions

  • Q1. For S1, should a nil field type mean "unknown" (Any), the way a nil env entry does, or should ObjectOf reject it? The fix differs, so the answer decides which one to write. (check.go:409)

Sensitive paths

  • CLAUDE.md: adds the types.go and check.go row to the layout table. The row matches the files.
Prompt for your AI tool: fix S1, S2, S3, S4, S5
Fix these review findings in deepnoodle-ai/expr, PR #29.

1. S1, check.go, func (c *checker) field: a field typed nil leaves field
   unchanged and later code reads .Kind on it. Wanted: return orAny(f) in
   the `case ok:` branch so nil never leaves field. Also check
   func elemFunc and func sameFunc, which read a[0].Kind. Test: an
   ObjectOf with a nil field, then first(obj.items), sameFunc callers, and
   obj[k] with a nil-typed k. Each must return without panicking.
2. S2, check.go, func (c *checker) iterating: narrowed entries for the
   bound name leak into the body, so x != nil && any(xs, x, x.a > 1)
   passes. Wanted: while checking body, remove c.narrowed entries equal to
   bind or starting with bind + ".", then restore them. Test: that
   expression must report "may be nil".
3. S3, check.go, func (c *checker) call: builtinTypes applies to a name
   that WithFunctions replaced. Wanted: skip builtinTypes for those names.
   Test: WithFunctions({"len": numberFn}) with len(5) must not be rejected.
4. S4, check.go, func (c *checker) index: index on a value that is always
   nil returns Null() silently. Wanted: report "the value is always nil"
   when !optional && c.lenient == 0, as func (c *checker) field does. Test:
   nil[0] must fail Check.
5. S5, check.go, func (c *checker) comparable: the test len(set.Enum) > 1
   skips single-value enums. Wanted: report "a" == "b" and EnumOf("a") ==
   "b". Test: both must fail Check with a never-true message.

Run the existing tests after the changes.

myzie and others added 2 commits October 28, 2026 10:32
- S1: a nil field type is Any, like a nil env entry; no panics.
- S2: a form's binding hides nil checks on the name it shadows.
- S3: builtin result types apply only when the registered function is the
  builtin, not a host function that replaced it.
- S4: indexing a value that is always nil is reported.
- S5: comparisons against single-value enums are checked.
- N1: only keyword field names are restored by the keyword rewrite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@myzie

myzie commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 2fb599e and ae64ca3. 2fb599e on its own doesn't build: one test needed a fix, which is in ae64ca3.

  • S1 (fixed): a nil field type now means Any, the same as a nil env entry, which answers Q1. first/reverse over it, obj[obj.k] and obj.items.deep no longer panic.
  • S2 (fixed): while a form's body is checked, its binding hides nil checks on the name it shadows, and on index too. x != nil && any(xs, x, x.a > 1) now reports "may be nil".
  • S3 (fixed): a builtin's result type applies only when the registered function is the builtin itself, compared by code pointer. A host len that takes numbers is no longer rejected.
  • S4 (fixed): nil[0] is reported as always nil.
  • S5 (fixed): comparisons against single-value enums are checked, so e == "b" with e: "a" is reported.
  • N1 (fixed): the rewrite restores a field name only when it is a keyword, so a user's x.__expr_kw_foo reads that field as written.

🤖 Generated with Claude Code

myzie added a commit to deepnoodle-ai/workflow that referenced this pull request Oct 28, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@mobiusops mobiusops Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of the new commits on this branch: 2fb599e ("fixes from review of #29") and ae64ca3 (test-only). I read the diff from 7d6c5fd. I did not run tests, builds, or linters, so CI is the check that these compile and pass.

Verdict: Comment. No blocking findings.

Previous findings, all addressed in code with regression tests in TestCheckReviewFixes:

  • S1 (nil field types panic): fixed. field treats a nil field type as Any. elemFunc and sameFunc nil-check their argument, and index uses orAny.
  • S2 (narrowing leaks into a shadowing binding): fixed. iterating hides narrowings on the binding name and index while the body is checked, then restores them. x != nil && any(xs, x, x.a > 1) now fails with "may be nil".
  • S3 (host function inherits a builtin's type): fixed. isBuiltin compares the registered function's code pointer with the builtin's. engine.go stores registered functions unwrapped, so the match holds for real builtins.
  • S4 (index on always-nil value): fixed. Reports "the value is always nil" outside try.
  • S5 (single-value enum comparison): fixed. The check runs for len(set.Enum) > 0. It only runs after the kinds match, and Join keeps an enum only when both sides carry one, so a union cannot trigger a false report.
  • N1 (prefix stripped from every selector): fixed. restoreKeywordFields restores only a prefixed name whose remainder is a Go keyword.
  • Q1 (meaning of a nil field type): answered in code. A nil field is unknown, matching a nil env entry. docs/reference/spec.md does not say so yet.

New, nit, non-blocking:

  • N2: ae64ca3 weakens a test assertion. TestKeywordPrefixOnlyRestoresKeywords changed from require.EqualValues(t, 1, v) to require.Equal(t, "1", fmt.Sprint(v)). The commit message says "fix test build", but I can't see a compile error in the file at 2fb599e. The new form also passes for any value that prints as 1. Could you say what failed? If the original compiled and only the assertion was wrong, please restore a typed check.
  • N3: spec. One sentence under "Static type checking" saying a nil field type is treated as any would document Q1.

The earlier follow-ups (S3–S5) are fixed too, so nothing from the previous review is still open.

myzie and others added 2 commits October 28, 2026 12:42
Keyword fields:
- B1: rename keyword field names before optaccess and jsonlit run, so
  event.range[0] and a.type()?.b compile.

Check now agrees with Run on:
- B2: unions. isAny no longer covers them, so builtins, iteration,
  indexing and == check each variant.
- B3: index types. Nil and union indexes, string receivers, and object
  keys that may be absent (use ?[).
- B4: object literal keys that are not strings or may be nil.
- B5: literal comparisons. == and != are no longer constant-folded, so
  "a" == 1 reaches Check.
- B6, B7: name resolution in Run's order (env, then registered
  functions, then special forms), using the user-visible name.
- M1: builtin calls. A signature table checks arity, argument kinds,
  nil and list element kinds; it replaces builtinTypes and nilIntolerant.
- M2: nil field types join as Any; entries keeps an open object's Elem.
- M3: a binding hides every narrowing that mentions it, not only prefix
  matches.
- try: a missing key read inside try is not nil.

Also: hints for undefined functions and scope names, and the spec now
says a nil *Type means Any.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@mobiusops mobiusops Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve: Updated for e636a20. Every earlier finding is fixed, and I found nothing new that blocks merge.

Fixed: B1–B7, M1–M3 from the second review (cbb2e25), S1–S5, N1, N2, and Q1 from the first. S1 and S2 need the fix, S3–S5 were follow-ups, and N2 was the weaker keyword test assertion, which is restored to require.Equal(t, 1, v).

Checked: the Check/Run agreement on name resolution (env, then registered functions, then forms, matching evalCall and resolveCallable), keyword rewrite now running before optaccess (no keyword is renamed after ?., so no string literal gets the prefix), union handling in Join/isAny, the builtin signatures against the native implementations (join, min/max, sprintf), nil collections in map/filter (Run treats nil as empty, so no nil check is needed there), and the try result join. I did not run tests, builds, or linters.

Draft status and "do not merge" are unchanged.

Adds docs/guides/type-checking.md with a runnable example and tests for
every claim in it and in the README section. Points the templates,
env and function guides at it, documents WithFuncType, adds Check to the
package doc and llms.txt, and tells contributors that Check must agree
with Run. Corrects the README's size comparison, which was stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@myzie myzie changed the title Static type checking (Program.Check) and keyword field names feat: type-check expressions, and allow keyword field names Oct 28, 2026
@myzie
myzie marked this pull request as ready for review October 28, 2026 16:53

@mobiusops mobiusops Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve at 9aae17d. No blocking findings.

The new commit is docs only: docs/guides/type-checking.md, examples/type_checking/, README and examples README, llms.txt, the spec, CLAUDE.md, engine.go doc comment, and docs_guides_test.go. I checked the guide's claims against the code at this head:

  • Every API the guide, README, and example use exists: Null, Bool, Number, String, EnumOf, ListOf, ObjectOf, OpenObject, Join, MathFuncs/StringFuncs/CollectionFuncs, WithFuncType, Template.Check, NewTemplate, and TypeError.Unwrap to ErrType.
  • The open-object Elem example matches types.go.
  • count in check.go resolves env, then registered functions, then forms, so the "registering count replaces the form" line is accurate.
  • The 0 | string union rendering matches the spec and the new TestGuide_TypeChecking expectations.

Not run: tests, builds, linters. I read the new test and example but did not compile them.

Nit (N3, non-blocking): the engine.go package doc example calls p.Check(env) without defining p. It's a comment, so nothing breaks, but a copy-paste will stop on it. Adding p, _ := Compile(...) above it would fix it.

The PR is still a draft with "Do not merge yet" in its body. This approval does not change that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@mobiusops mobiusops Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed ee08a3b (the new head, one commit on top of 9aae17d).

Verdict: Approve. Blocking: 0.

What changed: one commit, ee08a3b, adding three lines to docs/reference/spec.md (after the keyword field-name paragraph, around line 304). It states that identifiers beginning with __expr_ or __try_ are reserved for expr's internal use, and that this applies to field names.

Checked against the code:

  • The internal names that use these prefixes are __expr_map__, __expr_if__, __expr_kw_ (keyword field rewrite), __try_select__, and __try_index__ (engine.go, higher_order.go, internal/optaccess). The new sentence matches them.
  • The spec's "Reserved binding names" section is unchanged and still consistent.
  • No Go code changed in this push.

Not checked: I did not run tests, the example, or the docs tests, per the review constraints. I read the diff and the code it describes only. The earlier approval at 9aae17d still holds for everything other than this sentence.

Non-blocking, unchanged from before: the engine.go package doc example calls p.Check(env) without defining p.

@myzie
myzie merged commit 467de9d into main Oct 28, 2026
2 checks passed
@myzie
myzie deleted the typecheck branch October 28, 2026 17:00
myzie added a commit to deepnoodle-ai/workflow that referenced this pull request Oct 28, 2026
* proto: replace the runtime with a pure progression core

Apply(def, state, event) -> (state, commands) is the whole engine: no I/O,
no goroutines, no clock but Event.At. Parse/Validate own the graph
semantics (edges, when, match first, joins by step name, parallel each with
a concurrency cap, explicit retry with backoff, catch, timeouts, sleep, set,
on_failure, bounded loops). State is JSON and replaces checkpoints.

Deletes the goroutine-per-branch Execution, Runner, checkpointers, script
compiler plug-in, activities, workflowtest, examples, documentation and the
experimental worker and stores. A local driver and a Postgres host follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* proto: local driver and Postgres host over Apply

local runs commands in goroutines and feeds results back as events; its
checkpoint is the State, and Resume re-issues Outstanding work.

postgres (separate module, pgx) stores state, step records, an event log
and an outbox in the caller's transaction, and a Worker claims tasks with
SKIP LOCKED, heartbeats leases, fences results by claim count, and supports
deferred steps completed later through Deliver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* proto: CLI, examples, docs and findings for the progression design

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* proto: fix join merging found in review

- A join continues on the common ancestor only when the arrivals cover every
  path its last fork created; otherwise on a fresh <ancestor>.<join> path.
  Two partial joins could otherwise revive a finished path and dispatch a
  StepID twice.
- Scopes merge by visit number, not node-key string order.
- Join readiness uses reachability without loop back edges, so joins inside
  a loop no longer block each other.
- Settled-step records carry through merges and are cleared downstream when
  a loop re-enters a step, so a join may name a step merged earlier.
- The property test generates partial joins.
- postgres: a graceful release no longer counts toward MaxClaims.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* proto(postgres): claim only handled kinds; retry finishing deferred tasks

Addresses review on #42:
- B1: tasks carry step_kind and workers claim only kinds they handle, so
  workers with different handlers can share an outbox.
- S1: marking a deferred task done retries with backoff, so a database blip
  cannot make a handed-off step run again after its lease expires.
- Q1: document that a timeout counts from dispatch, including queue wait.
- N1: listen's retry sleeps honor ctx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* proto: typed definitions, switch routing, maybe-nil analysis

- Steps declare output schemas (output: inline, basic type, or a name under
  schemas:); hosts may supply param and output schemas per kind through the
  optional KindSchemas interface. Inputs and event_schema type the rest.
- Validate type-checks every expression and with: block with expr's new
  Program.Check: misspelled fields, wrong-kind operations, comparisons that
  are never true, with: that does not fit the kind's params.
- A dataflow pass decides what has settled before each step (as clauses, so
  converging alternatives work); anything else is maybe-nil and needs ?. or
  coalesce(...), which is non-nil when its arguments cover the alternatives.
- next: {switch, cases, default} routes on a value and is checked against
  its enum; when must be a boolean.
- Apply enforces the schemas: inputs and trigger at Start, params before
  dispatch (invalid_params), outputs on completion (invalid_output); absent
  declared properties read as nil; every step name is in the env.
- Commands carry OutputSchema for structured-output hosts.
- expr pinned to its typecheck branch (keyword field names: error.type).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* proto: close type soundness gaps found in review

Fixpoint settled analysis over every edge (multi-entry loops), coalesce
counts only steps whose value cannot be nil, input defaults validated and
completed, outputs/on_failure see caught {error} values, nullable enums,
untyped objects unchecked, unions rejected as when/each/switch. expr pinned
to the commit with operand-typed &&/||, narrowing, nil-intolerant builtins,
try/if arity, == on composites and union types. soundness_test.go holds every
counterexample from the review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* proto: pin expr with fixes from review of deepnoodle-ai/expr#29

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* proto: type outputs by what every completed run settled; precise catch reads

outputs were typed as if no step might have run, so even the start step
needed ?. there. They are now checked against the steps no completing run
can skip (a least fixpoint over the ways a run can avoid each step), plus
what held at every step a path can end on.

A step read on paths only its catch leads to is now just {error: ...}, so
a handler reads charge.error.message without ?.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* proto(postgres): keep finished work when a worker shuts down

A worker stopping just after a handler returned released the task, so a
completed result was discarded and a hand-off (ErrDeferred) ran again: a
second approval request or agent turn. Now only an error during shutdown
gives the claim back; a result or hand-off is recorded on a context
detached from shutdown and bounded by the lease.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* proto: add apply, typed, retry and Postgres approval examples

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* proto: ${{ }} templates and a single Load, from Mobius's review

Templates are now ${{ expr }}, as in GitHub Actions and Mobius routines.
${...} is plain text, so shell parameter expansion and JavaScript template
literals in with: values pass through, and $${{ writes a literal ${{.
There is no delimiter option: a definition is stored by hash and resumed
later, so its meaning must not depend on host configuration.

Parse + Validate(kinds) become Load(data, kinds) and LoadMap(doc, kinds).
A parsed but unvalidated definition served no purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build: use expr v1.3.0

v1.3.0 ships Program.Check and keyword field names, which typed
definitions use. Replaces the pseudo-version of the typecheck branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: expr's checker shipped in v1.3.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: findings name Load and expr v1.3.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: report nameless steps instead of panicking in graph analysis

Load panicked when a step had no name: the name was skipped for byName
but still counted as a valid target, so computeForward walked a nil
step. Graph analysis now runs only when every step has one unique name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: retry shorthand must be an integer

retry: 2.5 loaded as max_attempts 2 via int(n). The shorthand now uses the
same integer check as retry.max_attempts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: name missing edge and catch targets, reject repeats in switch cases

An edge object without step or a catch route without next reported an
unknown step ""; they now say what is missing. A switch case or default
that lists one step twice forked onto it twice; it is rejected like a
repeated next target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: any literal text around a template makes it a string

compileTemplate treated a single ${{ }} padded with whitespace as a typed
value, so "  ${{ 1 + 2 }}  " evaluated to 3. The docs say only a string
that is exactly one ${{ }} keeps its type; the code now agrees, and the
type checker follows because it reads the compiled template kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(postgres): serialize and version Migrate

Concurrent Migrate calls raced on CREATE SCHEMA and failed with a
duplicate key on pg_namespace, and CREATE TABLE IF NOT EXISTS could
never change an existing table. Migrate now runs in one transaction
under a per-schema advisory lock, applies numbered migrations once each,
records them in schema_migrations, and refuses a newer database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: a set value obeys MaxOutputBytes

enter charged a set value against MaxStateBytes but never checked it
against MaxOutputBytes. It now fails with output_too_large, like a task
output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schema): never mutate host-provided schemas

Load resolved refs by writing into the *Schema a host returned from
KindSchemas, including nested nodes. Concurrent Loads raced, and a second
definition's named schemas replaced the first's, so def1 rejected valid
outputs. Step, params, input and event schemas are now resolved into a
definition-owned copy (sharing within the schema is kept); only the
definition's own `schemas:` are resolved in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: a run's clock never runs backwards

Apply used ev.At as is, so a completion stamped an hour early gave
SettledAt before EnteredAt and scheduled timers early. State records
UpdatedAt, the time of the last event that changed it, and Apply runs at
max(ev.At, UpdatedAt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schema): reject recursive $ref schemas and bound their inlined size

A recursive schema (node with left/right: node) loaded, and marshalling a
command's output schema unrolled it to the depth cap, doubling per level,
which hung the postgres outbox. An alias cycle (a: b, b: a) loaded and
accepted any value. Load now rejects every ref cycle: expr types are
finite trees, so recursion could only be typed by cutting it off at a
depth past which reads go unchecked. Refs shared down a chain of named
schemas can still double the inlined size per level, so a schema's
inlined form is limited to 10000 nodes. With cycles gone, the depth caps
in validate, complete and MarshalJSON (which silently accepted deeper
values) are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: a failed each item cancels its siblings in flight

When an item failed without continue_on_error, Apply stopped new
dispatches but let the items in flight run on, and the step settled only
when they finished, holding up catch and the run failure. The step now
fails at once: running items get CancelStep (they are cancelled units, so
their late results are no-ops) and pending items are skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(postgres): report whether an event applied, and when a run finishes

Deliver returned the unchanged state with a nil error for a duplicate or
late event, indistinguishable from one that applied, and the terminal
commands were dropped. Start, Deliver, Cancel and their Tx forms now
return a Result with State, Commands and Applied. WithOnFinished calls a
hook in the transaction that finishes a run, and every status change is
notified on RunsChannel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(postgres): same limits warm or cold; validate before storing a definition

StartTx cached the caller's Definition with its own Limits while a cold
load used the store's, so a run's limits depended on which process
advanced it. StartTx now runs a copy with the store's limits (or the
cached definition), leaving the caller's untouched. It rejects a nil or
unloaded definition with ErrNotValidated before writing anything, caches
only after the run is stored, and inserts the run row whole instead of
inserting and then updating it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schema): keep nil and kinds for schemas without one specific shape

A bare {type: object} and every multi-kind schema (type: [string, number],
enum: ["x", 1]) converted to Any, which also drops "may be nil": b.k on a
step that may not have run, a.meta.k through an optional object, and
upper(a) on a string-or-number all validated and failed at run time.

The mapping is now: an object with no properties and no
additionalProperties is an open object (kind and nil checked, fields
untyped); listed types are joined into a union; an enum is the union of
its values' literal types, restricted to the values type allows; a schema
that names no kind ({}) is untyped, like a step with no schema. Types are
converted once per schema node.

nullable now agrees with the type: null in an allowed enum value makes a
schema nullable, so a required {enum: ["a", null]} accepts null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(postgres): settling an attempt removes all its tasks and records why

A deferred step whose worker died before marking its task done kept that
task after the result arrived through Deliver, so another worker ran the
handler again. Any applied event that settles an attempt now deletes the
attempt's step task as well as its timer.

A timed-out attempt that was retried was recorded cancelled with no
error, because the state no longer holds the replaced attempt. The step
record now comes from the run's view: the accepted output, the error
Apply recorded, or for a retried attempt the error its replacement
carries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schema): complete additionalProperties values; exact integer test

complete returned early for an object with no properties and never
walked additionalProperties, so an absent optional property of a value
under additionalProperties was a missing key at run time
(a?.alice.score: key "score" not found). It now completes those values
with the additionalProperties schema.

The integer test converted to int64, which overflows past 2^63: 1e19
was rejected as not an integer, and 2^63 passed only where the
conversion saturates. It is now math.Trunc(f) == f.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(typecheck): switch keys for numbers match Switch.route

switchKey used fmt.Sprint while Switch.route uses json.Marshal, so with
enum [1, 1000000] Load rejected the case "1000000" and accepted "1e+06",
which never routes. switchKey now uses the same encoding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(postgres): settle outputs Postgres cannot store as invalid_output

A string with a NUL character (rejected by jsonb) or a NaN (rejected by
json.Marshal of the event) rolled back the settle, so the lease expired
and the handler ran again, up to MaxClaims, ending in a misleading
abandoned. DeliverTx now normalizes a completion's output before Apply
and turns one it cannot store into a non-retryable invalid_output
failure; NULs in error messages and reasons are replaced. The worker
also fails the step when Postgres raises a data exception for a result.
Start reports ErrUnstorable for such inputs, trigger or metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: a run holds only what it can still read

State.Bytes only ever grew and State.Nodes kept every visit, so a
poll/sleep loop, which validation accepts as bounded by its sleep, failed
with state_too_large after enough passes (the 12th, for 900-byte polls
under a 10 KiB cap; about 13 hours for a 10 KB poll a minute at the
8 MiB default).

Apply now drops step visits nothing can read any more and releases their
bytes. Values are read only through path scopes (and a path's current
node, for default outputs), so a settled node is kept when it is a path's
current node, in the scope of a path that has not ended, or the highest
visit of its step in any scope (what outputs and on_failure read).
Anything else was superseded by a newer visit of the same step. Each node
records the bytes it holds; State.Bytes is their sum, and charge collects
before failing a value over the cap. A result for a dropped visit is a
late delivery (its path has made that visit), not an unknown step.

A differential test runs random graphs in a loop, with every step read by
params and outputs, and checks the commands and outputs match a run that
keeps every visit, in shuffled orders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf: build each settled value once per Apply

env rebuilt every scoped step's value for every expression, allocating a
fresh list per each step in scope. Settled nodes do not change, so the
value is cached per node key for the duration of one Apply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(postgres): fence claims with a token that never goes back

The claim count was both the fencing token and the MaxClaims counter, and
a graceful release decremented it, so after W2 claimed and released a
task, a stalled W1 holding the old count could settle it. tasks.fence now
grows on every claim and is never decremented; heartbeat, settle, finish
and release all check it, while claims stays the refundable attempt
counter. The heartbeat also cancels the handler once no heartbeat has
succeeded for a whole lease, so a partitioned worker stops running a task
another worker may hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(typecheck): infer set-step types to a fixpoint

A set step's type was known only once its own check ran in topological
order, so a step reading a later set step around a loop saw Any:
s?.v + 1 and coalesce(s?.v, 0) * 2 validated against set: {v: "str"}
and failed at run time. Set types are now solved before checking: each
starts as nil and set expressions are re-typed until none changes. A
type that keeps growing is reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(local): cancel a run; drop settled timers and unchanged saves

local.Runner had no way to cancel a run: a cancelled ctx pauses it. Run
and Resume now cancel the run when ctx is cancelled with the cause
local.Cancel(reason): the runner applies EventCancel, saves the cancelled
state with a live context, cancels the handlers and returns the state
with a nil error. A plain cancellation still pauses.

Timeout timers outlived their attempts: a step that finished normally
left its timer in the map until it fired. The driver now drops an
attempt's timer once its result is applied, deletes timers when they fire
and on CancelStep, and releases each step context when its handler
returns. Save is skipped when Apply returns the state unchanged (a
duplicate or late event), as hosts.md advises.

hosts.md and event.go now say that settling an attempt makes its timeout
timer obsolete, that a host may drop it, and that a stale TimerFired is a
no-op; and how the run clock treats an early Event.At.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(typecheck): check unions and an absent with: against params

assign returned early on KindAny, which includes unions, so
with: {n: "${{ a.name || 0 }}"} against n: number validated and failed
with invalid_params. Each variant is now checked. A value's kind is also
checked against an enum-only schema's values.

A step with no with: dispatches empty params, which Apply validates
against the kind's params schema, but Load checked only a with: that was
written. It now checks an absent with: as an empty object, for steps and
on_failure steps alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(postgres): settle within the lease, with retries; keep permanent failures on shutdown

settle ran on context.WithoutCancel with no deadline, so shutdown could
hang in wg.Wait behind a row lock, and it never retried, so a connection
blip lost a finished result and the reclaim ran the handler again. It now
retries transient errors with backoff until the claim's lease (from the
last successful heartbeat) runs out, and fails an unstorable result as
invalid_output. Timer tasks settle the same way instead of on the
cancellable context, and a timer that cannot fire during shutdown is
given back.

On shutdown the worker released every errored claim, so a handler's
non-retryable StepError was run again by another worker and the run
could complete. It now releases only an error that may be due to the
shutdown (a context error or a retryable error) and settles a permanent
failure.

settle also locked and decoded the run twice; it now applies the event
to the state it locked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): shell params override the environment; stdout is only the result

The shell handler appended params before os.Environ(), so an inherited
variable beat a with: parameter of the same name (with: {home: x} still
saw the real $HOME). The environment is now inherited first and params
appended in sorted order, so they win.

print wrote to stdout, so `workflow run -json` was not valid JSON. Step
output (print, shell stderr) now always goes to stderr and stdout carries
only the result. An interrupt now cancels the run through local.Cancel
instead of leaving it running. The usage text documents all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: soundness cases for the second review's counterexamples

Each finding gets a definition, a schema-conforming output and a run that
must be rejected at Load or complete: additionalProperties completion,
bare and multi-kind schemas, mixed enums, number switch keys, set steps
across back edges, nullable enums, big integers, and (with host schemas)
unions and absent with: against params. TestOutsideThePromise pins what
the promise does not cover, and TestJoinAbsentFields holds the A2.1 cases
for the expr fix, skipped until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: schema-to-type mapping and what the promise does not cover

definitions.md gets the mapping table, nullability, the ref-cycle and
size rules, host schema ownership, and a list of what the promise leaves
out: untyped values (including {} and inputs without an inputs block),
index and value errors, and params Load cannot prove (checked at
dispatch). typed-definitions.md records the second review and the design
choices; llms.txt summarizes both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(postgres): fail a run whose definition cannot load; bound timers by MaxClaims

A run whose stored definition used a kind the store did not know looped
forever: handlers ran and their settle failed to load the definition,
timers skipped MaxClaims, and the abandoned settle failed the same way.
The worker now checks the run's definition before it runs anything. A
definition that does not load (ErrDefinitionUnavailable, also returned by
Deliver) postpones the task a lease, doubling, and after MaxClaims claims
fails the run with definition_unavailable without Apply: in-flight work
is cancelled, tasks are dropped, an aborted entry is logged and the
finish hook and notification fire. A timer claimed more than MaxClaims
times, or a step whose abandoned failure cannot be applied, fails the run
the same way. A definition that loads with a different hash is reported
as unavailable instead of being dropped as a definition mismatch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(postgres): settle a step record even when its unit left the state

The step record's outcome came from next.Unit(ev.StepID), which is nil
not only when a retry replaced the attempt but also when Apply drops a
superseded visit in the same event. With no unit and no replacement the
record now settles from the event: completed with the output, failed
with the reported error, and for a timer completed (a sleep) or failed
with a timeout, so no record stays dispatched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(postgres): LISTEN on a dedicated connection the worker closes

The listener was not in the WaitGroup, acquired a pool connection it
held for the worker's life, and on cancel returned it to the pool still
LISTENing, where pgx buffers notifications without limit. With
MaxConns=1 the worker deadlocked. The listener now runs in the WaitGroup
on its own connection (Worker.ListenConfig, default the pool's config),
reconnects after errors, catches up after each reconnect, and closes the
connection on exit. hosts.md documents pool sizing and poolers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(postgres): give the claim query an index it can use

tasks_due (run_at, id) matched neither the step-kind filter nor the
lease filter: an action worker read through every due agent task (50,500
rows filtered in a probe) to find its own. Tasks now carry a generated
queue column ('timer' or 'step:<kind>') indexed with (queue, run_at,
id), and the claim reads each of the worker's queues in due order up to
its limit through a LATERAL subquery. A test checks the plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(postgres): reorganize the host guide around runs, events and workers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(postgres): give a claim back when shutdown interrupts the definition check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(postgres): tasks due now use the database clock

A task inserted with the app server's clock as run_at is not claimable
until Postgres's now() catches up, so clock skew delayed it and made
claim-right-after-start tests flaky under load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: let only a join that gathers a whole fork continue on its path

A join continues on the arrivals' common ancestor when they cover that
ancestor's last fork. Covering went through each arrival's lineage and
its ancestors' merges, so two joins that each took one branch of every
inner fork both covered the outer fork, and whichever fired first took
the ancestor's path: the StepIDs after the joins depended on completion
order. Loops made this common, since a merged lineage covered every later
partial join.

A path now stands only for itself and the paths merged into it, and a
fork's child counts as covered when it arrived or when everything it
forked into is covered in turn. No two firings can cover the same fork.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: make joins in and after loops independent of completion and edge order

Joins decided reachability over "forward" edges: reach with the back
edges of one depth-first walk removed. Removing every back edge made a
path sitting in an upstream poll loop look unable to reach a later join,
so the join fired early and its successor ran twice. The walk also
followed declaration order, so listing a step's edges differently
classified different back edges and changed which steps a loop pass
reset; one order failed a join that the other completed.

Loops now come from the graph alone. Each strongly connected set of
steps is a loop. Its heads are its entry steps and its back edges go from
inside it to a head; removing them reveals the inner loops. A pass of a
loop starts each time a path enters its head, and a path's pass is its
head visits.

A join fires once per pass of the loops it is in. Waiting paths form
cohorts by pass. A cohort fires when no live path can still arrive in
that pass: a path in the same pass must be able to reach the join
without entering a head of the join's loops, and a path behind from loop
i inward without entering a head of the loops outside i. Paths in loops
the join is not in always count, so a join after a loop waits for it.
This predicate only goes from true to false, so every cohort has the same
arrivals, and cohorts fire in the same order, whatever order events come
in. Path.Since becomes Path.Settled, which records each step's pass. A
named step counts if it settled in the current pass of the loops it
shares with the join, so a join after a loop sees any pass.

Load now rejects a join inside a loop with more than one entry step,
whose passes are ambiguous, and a join naming a step it only follows
through the head of a loop they share, which could never fire.

The property test generator now also makes bounded loops: joins inside,
after and around them, and fork branches that jump back to a loop head
or to their fork without reaching the join. A loop-heavy variant and a
step-order permutation check are added; the order count scales with run
size to keep the suite quick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: loop joins fire the same whether visits are dropped or kept

Adapts loopWorkflow to the generator's new block(depth, head) signature
after merging the join and runtime branches, and runs the loop-join
graphs with and without dropping superseded visits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build: use expr v1.3.1; read fields of joined shapes with ?.

expr v1.3.1 marks a field that only one joined object shape has as
possibly absent. TestJoinAbsentFields now runs, with reviewer A's each
and catch cases, and TestJoinAbsentFieldsSafeReads checks the ?. forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: llms.txt and CLAUDE.md describe the new postgres API

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor!: one run input replaces inputs and the trigger

A run now takes a single `input`: a JSON object, typed by an optional
`input:` object schema (inline or a name under `schemas:`) and read in
expressions as `input`. Required fields come from the schema's `required`
and defaults from each property's `default`, which Load checks against
the property's schema and Apply fills in at Start (and in step outputs).
A property with a default is typed non-nil.

The `inputs:` block, `event_schema`, the `inputs` and `event` globals,
Event.Inputs/Trigger, State.Inputs/Trigger and ErrInvalidInputs are gone;
Event.Input, State.Input and ErrInvalidInput replace them. The engine has
no notion of a trigger: hosts put a triggering event into the input, by
convention as {event: <payload>, meta: {...}}, read as input.event.x.

local.Runner.Run takes one input. The CLI takes -input-file FILE and
-input k=v (overriding top-level fields). postgres.NewRun takes Input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: long form for outputs with a schema and a description

An outputs entry may be {value, schema, description}, mixed with the
short form. Load checks the value's type against the schema with the
same machinery as with: against params; at completion Apply validates
each value and fails the run with invalid_output on a mismatch.
Definition.Outputs exposes each output's value, schema and description,
and OutputSchema() packages them as one JSON Schema object for hosts
that publish a workflow as a tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(postgres)!: the outbox holds jobs, not tasks

"Task" is retired vocabulary. postgres.Task is now Job and Handler takes
a Job; the tasks table, its claim index and the NOTIFY channel are jobs,
jobs_claim and <schema>_jobs. Migration 1 is unreleased, so it is edited
in place. Comments, log messages, tests and docs follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: the example step kind is action, not task

Tests, fixtures and docs used "task" as the stand-in host kind. It is now
"action", the kind the examples already use, so "task" is gone from the
vocabulary. Kind maps that listed both drop the duplicate. The unloadable
definition test now withholds the agent kind instead of action, so its
cold store still lacks a kind the run needs. Two leftover uses of "task"
for a step's output say "step".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: finally steps run once the run's outcome is decided

A top-level finally: list runs in order after the run completes (after
outputs), fails (after on_failure) or is cancelled (after in-flight work
is cancelled). Each step runs even if an earlier one failed, and reads
the outcome as run = {status, error}, every other step as maybe-nil, and
earlier finally steps, a failed one as {error: ...}.

A failing finally step never changes the outcome; it is recorded in
State.FinallyErrors and on the terminal command, which is emitted only
when the finally steps are done. While they run the status is
completing, failing or cancelling (RunStatus.Outcome maps them). A
cancel during finally cancels the step in flight, skips the rest
(reported as type cancelled) and finishes with the outcome already
decided.

local runs finally steps after a cancel on a detached context; postgres
passes FinallyErrors to WithOnFinished. Order independence is covered by
a property test over random graphs for every outcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(examples): the retry example audits every outcome in finally

The retry example gains a finally step that records each charge's
outcome and a long-form output with a schema and a description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build: ignore the CLI binary built at the repo root

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: host-aborted runs skip finally; jobs of an unhandled kind wait

A postgres abort ends a run without the engine, so it runs neither
on_failure nor finally steps. definitions.md and llms.txt said finally
runs however a run ends; they now name the exception. hosts.md says a
job whose kind no worker handles waits, and that a timeout bounds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(planning): the outbox holds jobs; NewRun takes Input

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(readme): note the redesign and the last stable commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(planning): postgres replaces the experimental packages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant