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
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,21 @@ crates/wavekat-flow/ # wavekat-flow (crate) — generated typ
- **The logic lives here too.** Beyond the generated model, each package
carries its language's hand-written half of the format:
[`@wavekat/flow-schema`](./packages/flow-schema/README.md) — safe-subset
YAML parsing, semantic validation, hours math, comment-preserving source
edits, and flow diffing (authoring side);
YAML parsing, semantic validation, hours math, structured edits, and flow
diffing (authoring side);
[`wavekat-flow`](./crates/wavekat-flow/README.md) — semantic validation,
hours math, and the flow interpreter with its run trace (execution side).
Both write one **canonical text** for a flow, byte for byte the same, so
editing is parse → change the model → serialize in either language. See
[`docs/01-canonical-document.md`](./docs/01-canonical-document.md).

## What a schema *cannot* own

A JSON Schema defines the document **shape** (types, enums, required fields,
the `kind` discriminated union, defaults). It cannot express the format's
**semantic rules** — graph reachability, exit-set exactness, hours/timezone
math, DTMF digit validity, prompt length, safe-subset YAML parsing,
comment-preserving edits, or the interpreter. Those live here as hand-written
math, DTMF digit validity, prompt length, safe-subset YAML parsing, the
canonical serialization, or the interpreter. Those live here as hand-written
code per language — **pinned by the shared corpus**, not generated. See
[`schema/README.md`](./schema/README.md) for the exact boundary.

Expand Down Expand Up @@ -92,7 +95,7 @@ in **[`docs/ROADMAP.md`](./docs/ROADMAP.md)**.
corpus + back-compat runner, and both packages generating their model types
from the schema, corpus green in TS and Rust.
- **Phase 2 — full consolidation. Done.** The hand-written logic moved in
(TS: safe-subset parse, comment-preserving mutation, semantic validation,
(TS: safe-subset parse, structured edits, semantic validation,
hours; Rust: validation, hours, engine, trace), each adapted to the
generated types, with the corpus extended to pin the semantic outcomes in
both languages.
Expand Down
23 changes: 23 additions & 0 deletions conformance/canonical/comments.canonical.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
schema_version: 2
id: flow_comments
name: "Comments # are not data"
entry: hi
nodes:
hi:
kind: greeting
note: C# is a note, not a comment
prompt: "It's # not a comment"
exits: { next: book }
book:
kind: book
prompt: "# inside a block scalar\nHere are the times.\n"
confirm_prompt: { audio: vprompt_c0, transcript: You're booked for }
schedule:
mon:
- { open: "09:00", close: "17:00" }
timezone: UTC
duration_mins: 30
exits: { booked: bye, no_slots: bye, no_input: bye, unavailable: bye }
bye:
kind: hangup
prompt: "multi line # still quoted"
4 changes: 4 additions & 0 deletions conformance/canonical/comments.expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"description": "Comments are found (and counted) the same way in both languages, and dropped by the canonical text. A `#` inside a quoted scalar, a block scalar, or after a non-blank character is not a comment.",
"comments": 5
}
26 changes: 26 additions & 0 deletions conformance/canonical/comments.input.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# A flow with comments: none of them survive a save.
schema_version: 2
id: flow_comments # platform id
name: "Comments # are not data"
entry: hi
nodes:
# The greeting.
hi:
kind: greeting
prompt: 'It''s # not a comment'
note: C# is a note, not a comment
exits: { next: book } # trailing
book:
kind: book
prompt: |
# inside a block scalar
Here are the times.
# after the block scalar
confirm_prompt: { audio: vprompt_c0, transcript: "You're booked for" }
schedule:
mon: [{ open: "09:00", close: "17:00" }]
timezone: UTC
duration_mins: 30
exits: { booked: bye, no_slots: bye, no_input: bye, unavailable: bye }
bye: { kind: hangup, prompt: "multi
line # still quoted" }
24 changes: 24 additions & 0 deletions conformance/canonical/defaults.canonical.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
schema_version: 1
id: flow_defaults
name: Defaults are left out
entry: welcome
nodes:
welcome:
kind: greeting
prompt: Hello.
exits: { next: menu }
menu:
kind: menu
prompt: Press 1 for sales.
options: { "1": Sales }
exits: { "1": vm, no_input: vm, invalid: vm }
vm:
kind: message
prompt: Leave a message.
open:
kind: hours
schedule: {}
timezone: UTC
exits: { open: welcome, closed: vm }
bye:
kind: hangup
4 changes: 4 additions & 0 deletions conformance/canonical/defaults.expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"description": "Optional fields equal to their schema default, and empty optional maps and lists, are left out; required ones are written even when empty (`schedule: {}`).",
"comments": 0
}
31 changes: 31 additions & 0 deletions conformance/canonical/defaults.input.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
schema_version: 1
id: flow_defaults
name: Defaults are left out
version: 1
entry: welcome
nodes:
welcome:
kind: greeting
prompt: Hello.
exits: { next: menu }
menu:
kind: menu
prompt: Press 1 for sales.
options: { "1": Sales }
retries: 1
timeout_secs: 5
exits: { "1": vm, no_input: vm, invalid: vm }
vm:
kind: message
prompt: Leave a message.
max_secs: 120
tone: beep
exits: {}
open:
kind: hours
timezone: UTC
schedule: {}
exceptions: []
exits: { open: welcome, closed: vm }
bye:
kind: hangup
21 changes: 21 additions & 0 deletions conformance/canonical/order.canonical.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
schema_version: 1
id: flow_order
name: Order
version: 3
note: Order of nodes is the author's; integer ids go first.
entry: start
nodes:
"2":
kind: hangup
"10":
kind: hangup
start:
kind: greeting
note: First thing a caller hears.
prompt: Welcome.
exits: { next: pick }
pick:
kind: menu
prompt: Choose.
options: { "0": Operator, "1": Sales, "2": Support, "*": Repeat, "#": Exit }
exits: { "0": start, "1": start, "2": start, invalid: start, "#": start, "*": start, no_input: start }
4 changes: 4 additions & 0 deletions conformance/canonical/order.expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"description": "Schema-described objects are written in schema property order (`note` after `kind` and after `version`). Other maps keep document order, except that integer keys come first in ascending order, as JavaScript objects order them.",
"comments": 0
}
21 changes: 21 additions & 0 deletions conformance/canonical/order.input.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
nodes:
start:
exits: { next: pick }
prompt: Welcome.
note: First thing a caller hears.
kind: greeting
pick:
options: { "2": Support, "1": Sales, "0": Operator, "*": Repeat, "#": Exit }
exits: { invalid: start, "#": start, "*": start, "2": start, no_input: start, "0": start, "1": start }
kind: menu
prompt: Choose.
"10":
kind: hangup
"2":
kind: hangup
entry: start
note: Order of nodes is the author's; integer ids go first.
name: Order
version: 3
id: flow_order
schema_version: 1
32 changes: 32 additions & 0 deletions conformance/canonical/scalars.canonical.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
schema_version: 1
id: flow_scalars
name: "Quotes: only where needed"
entry: a
nodes:
a:
kind: greeting
note: "yes"
prompt: Thanks for calling, how can we help?
exits: { next: b }
b:
kind: menu
prompt: "It's 9: press 1 or # for more"
options: { "1": "Sales, new orders", "2": Plain label, "3": "Ext 5:30" }
exits: { "1": c, "2": c, no_input: c, invalid: c }
c:
kind: message
note: " leading space"
prompt: "Please leave a message\nafter the tone.\n"
exits: { next: d }
d:
kind: hours
schedule:
mon:
- { open: "09:00", close: "17:00" }
timezone: America/New_York
exceptions:
- { date: "2026-12-25", closed: true }
exits: { open: e, closed: e }
e:
kind: hangup
prompt: "Goodbye — 再见.\tTab, \"quotes\" and a backslash \\"
4 changes: 4 additions & 0 deletions conformance/canonical/scalars.expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"description": "Plain where the string can't be misread, double-quoted otherwise: reserved words, times, dates, leading digits, `: ` and ` #`, leading or trailing spaces, flow indicators and `:` in flow style, and anything needing an escape. Multi-line text becomes one double-quoted line.",
"comments": 0
}
33 changes: 33 additions & 0 deletions conformance/canonical/scalars.input.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
schema_version: 1
id: flow_scalars
name: 'Quotes: only where needed'
entry: a
nodes:
a:
kind: greeting
prompt: Thanks for calling, how can we help?
note: "yes"
exits: { next: b }
b:
kind: menu
prompt: 'It''s 9: press 1 or # for more'
options: { "1": "Sales, new orders", "2": Plain label, "3": "Ext 5:30" }
exits: { "1": c, "2": c, no_input: c, invalid: c }
c:
kind: message
prompt: |
Please leave a message
after the tone.
note: " leading space"
exits: { next: d }
d:
kind: hours
timezone: America/New_York
schedule:
mon: [{ open: "09:00", close: "17:00" }]
exceptions:
- { date: "2026-12-25", closed: true }
exits: { open: e, closed: e }
e:
kind: hangup
prompt: "Goodbye — 再见.\tTab, \"quotes\" and a backslash \\"
22 changes: 22 additions & 0 deletions conformance/canonical/ui.canonical.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
schema_version: 2
id: flow_ui
name: The ui block is opaque
entry: a
nodes:
a:
kind: hangup
prompt: { audio: vprompt_a1, transcript: Bye }
ui:
zoom: 1
grid: { snap: true, size: 0.5, origin: null }
positions:
"1": [0, 0]
a: [120, 40.5]
tiny: 1e-7
huge: 1e+21
layers:
- { name: main, visible: true }
- [1, 2]
- []
- {}
tags: []
4 changes: 4 additions & 0 deletions conformance/canonical/ui.expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"description": "`ui` is written as it was given, with nothing left out: collections of scalars on one line, numbers as JavaScript writes them, integer keys first.",
"comments": 0
}
23 changes: 23 additions & 0 deletions conformance/canonical/ui.input.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
schema_version: 2
id: flow_ui
name: The ui block is opaque
entry: a
nodes:
a:
kind: hangup
prompt: { audio: vprompt_a1, transcript: Bye }
ui:
zoom: 1.0
grid: { snap: true, size: 0.5, origin: null }
positions:
a: [120, 40.5]
"1": [0, 0]
tiny: 0.0000001
huge: 1.0e+21
layers:
- name: main
visible: true
- [1, 2]
- []
- {}
tags: []
5 changes: 4 additions & 1 deletion crates/wavekat-flow/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ exclude = ["tests/"]
anyhow = "1"
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# `preserve_order`: the opaque `ui` block keeps the key order it was
# written in, which the canonical serialization depends on.
serde_json = { version = "1", features = ["preserve_order"] }
indexmap = { version = "2", features = ["serde"] }
serde_yaml_ng = "0.10"
thiserror = "2"
time = { version = "0.3", features = ["macros", "parsing"] }
Expand Down
7 changes: 5 additions & 2 deletions crates/wavekat-flow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ this crate.
|--------|--------------|
| `model` (re-exported at root) | Generated document types (`Flow`, `Node`, `Prompt`, …) plus hand-written helpers (`Flow::from_yaml`, `Node::exits`, …) |
| `validate` | Semantic validation: reachability, caller traps, exit-set exactness, DTMF and prompt rules |
| `edit` | Load a document to edit it: `edit::load` refuses unknown fields (a save would delete them) and unsupported versions, and returns the YAML comments a save drops; `edit::load_draft` fills a new draft's missing `id`/`name` |
| `Flow::to_yaml` | The canonical text of a flow, byte for byte what the TypeScript `serializeFlow` writes. Edit by parsing, changing the flow, and calling this |
| `hours` | Business-hours / timezone math (IANA zones, holiday exceptions) |
| `engine` | The flow interpreter + the `FlowEffects` async trait your runtime implements |
| `trace` | Serializable run traces emitted by the engine |
Expand Down Expand Up @@ -97,8 +99,9 @@ meantime, which is the safe direction.

## One format, two languages

This crate is the Rust half of the format. Authoring, comment-preserving
edits, and the TypeScript twin of the validator live in
This crate is the Rust half of the format. The structured edit operations the
web editor uses, and the TypeScript twins of the validator and the canonical
serializer, live in
[`@wavekat/flow-schema`](https://www.npmjs.com/package/@wavekat/flow-schema).
The schema, the shared conformance corpus, and the full design rationale live
in the [`wavekat-flow` repository](https://github.com/wavekat/wavekat-flow).
Expand Down
Loading
Loading