From c0a75e3404268f0fc91bb82e8deda2cafac98e1d Mon Sep 17 00:00:00 2001 From: Eason WaveKat Date: Sun, 9 Aug 2026 22:00:19 +1200 Subject: [PATCH 1/3] feat(book)!: half-hour vocabulary grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: `BOOK_GRANULARITY_MINS` moves from 15 to 30, so `bookVocabularyRefs` / `vocabulary_refs` return a smaller set for the same node — 63 clips per voice rather than 111, and 29 rather than 44 for a Mon-Fri 09:00-17:00 step with 30-minute appointments. Consumers must not adopt this blind. `required_assets` is computed from the constant compiled into each build, not from anything a version carries, so a daemon still on the quarter hour that receives a version published after this change asks for `bktime_0915`, does not find it, and refuses to arm the flow — silently, and for the whole flow, not just its booking step. The safe order is: platforms narrow what they *offer* first, leaving the frozen set a superset of what every daemon asks for; fleets update; only then does this land. The platform side of that argument is its docs/34 §2. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018stjkZJ4kxDViq1ENnVDmH --- crates/wavekat-flow/src/book.rs | 38 ++++++++++++++++---------- crates/wavekat-flow/src/engine.rs | 2 +- packages/flow-schema/src/book.ts | 19 +++++++++---- packages/flow-schema/test/book.test.ts | 18 +++++------- 4 files changed, 46 insertions(+), 31 deletions(-) diff --git a/crates/wavekat-flow/src/book.rs b/crates/wavekat-flow/src/book.rs index db8b5f8..6df95e8 100644 --- a/crates/wavekat-flow/src/book.rs +++ b/crates/wavekat-flow/src/book.rs @@ -12,12 +12,12 @@ //! //! So the times a flow can *ever* offer are enumerated at publish and //! rendered then, exactly like its prompts. Two things make that a small -//! finite set rather than an impossible one: starts snap to a quarter -//! hour ([`BOOK_GRANULARITY_MINS`]), and the node already declares the -//! only hours it books in. A business open 9–5 on weekdays therefore -//! needs 32 time clips, not 1440 — and needs them regardless of which -//! week the caller rings in, because "nine thirty" is the same two words -//! on every one of those days. +//! finite set rather than an impossible one: starts snap to a half hour +//! ([`BOOK_GRANULARITY_MINS`]), and the node already declares the only +//! hours it books in. A business open 9–5 on weekdays therefore needs 16 +//! time clips, not 1440 — and needs them regardless of which week the +//! caller rings in, because "nine thirty" is the same two words on every +//! one of those days. //! //! # Why it is split day + time //! @@ -78,13 +78,22 @@ pub const MAX_BOOK_HORIZON_DAYS: u64 = 31; /// digits the vocabulary carries. pub const MAX_BOOK_OFFERS: u64 = 5; -/// Every candidate appointment start lands on a quarter hour. +/// Every candidate appointment start lands on a half hour. /// /// Load-bearing, not cosmetic: this is what bounds the vocabulary. /// Twin: the platform's `SLOT_GRANULARITY_MINS`, which must agree — if /// the server offers a time the vocabulary has no clip for, the caller /// hears silence where the time should be. -pub const BOOK_GRANULARITY_MINS: u64 = 15; +/// +/// **This constant is why the change is staged.** [`required_assets`] +/// is computed from the value compiled into *this* build, not from +/// anything a version carries — so a daemon still on the quarter hour, +/// handed a version published after this moved, asks for `bktime_0915`, +/// does not find it, and refuses to arm the flow at all. Platforms +/// narrow what they offer first; this follows once the fleet has it. +/// +/// [`required_assets`]: crate::model_ext::required_assets +pub const BOOK_GRANULARITY_MINS: u64 = 30; // ── Vocabulary refs ──────────────────────────────────────────────────── @@ -190,7 +199,7 @@ fn minutes_of(hhmm: &str) -> Option { (hours <= 23 && mins <= 59).then_some(hours * 60 + mins) } -/// The starts a single open range can produce: on the quarter-hour grid, +/// The starts a single open range can produce: on the half-hour grid, /// from the first grid point at or after `open`, while the whole /// appointment still finishes by `close`. /// @@ -324,9 +333,10 @@ mod tests { open: "09:10".into(), close: "10:30".into(), }; - // First grid point at or after 09:10 is 09:15; the last start that - // still finishes by 10:30 with a 30-minute appointment is 10:00. - assert_eq!(starts_in_range(&range, 30), vec![555, 570, 585, 600]); + // First grid point at or after 09:10 is 09:30 — never 09:10, and + // no longer 09:15; the last start that still finishes by 10:30 + // with a 30-minute appointment is 10:00. + assert_eq!(starts_in_range(&range, 30), vec![570, 600]); // An appointment longer than the window produces nothing at all. assert!(starts_in_range(&range, 120).is_empty()); } @@ -419,13 +429,13 @@ mod tests { #[test] fn an_offer_ends_with_the_key_that_takes_it() { let tz = crate::hours::resolve_tz("UTC").unwrap(); - let start = datetime!(2026-07-07 09:15 UTC); + let start = datetime!(2026-07-07 09:30 UTC); let now = datetime!(2026-07-07 08:00 UTC); assert_eq!( offer_refs(start, now, tz, 2), vec![ "bkday_today".to_string(), - "bktime_0915".to_string(), + "bktime_0930".to_string(), "bkpress_2".to_string() ], ); diff --git a/crates/wavekat-flow/src/engine.rs b/crates/wavekat-flow/src/engine.rs index 544b329..8d29279 100644 --- a/crates/wavekat-flow/src/engine.rs +++ b/crates/wavekat-flow/src/engine.rs @@ -1119,7 +1119,7 @@ nodes: } /// Two Tuesday-morning slots, in the vocabulary the CLINIC schedule - /// renders (09:00–11:30 on the quarter hour). + /// renders (09:00–11:30 on the half hour). fn two_slots() -> SlotOffer { SlotOffer { slots: vec![ diff --git a/packages/flow-schema/src/book.ts b/packages/flow-schema/src/book.ts index ff32499..421c461 100644 --- a/packages/flow-schema/src/book.ts +++ b/packages/flow-schema/src/book.ts @@ -14,10 +14,10 @@ // rendered then, exactly like its prompts. Two things make that a small // finite set rather than an impossible one: // -// * starts snap to a quarter hour ({@link BOOK_GRANULARITY_MINS}), and +// * starts snap to a half hour ({@link BOOK_GRANULARITY_MINS}), and // * the node already declares the only hours it books in. // -// A business open 9–5 on weekdays therefore needs 32 time clips, not +// A business open 9–5 on weekdays therefore needs 16 time clips, not // 1440 — and it needs them regardless of which week the caller rings in, // because "nine thirty" is the same two words on every one of those days. // @@ -83,15 +83,24 @@ export const MAX_BOOK_HORIZON_DAYS = 31; export const MAX_BOOK_OFFERS = 5; /** - * Every candidate appointment start lands on a quarter hour. + * Every candidate appointment start lands on a half hour. * * Load-bearing, not cosmetic: this is what bounds the vocabulary above. * Widening it widens every published flow's render. Twin: the platform's * `SLOT_GRANULARITY_MINS`, which must agree — if the server offers a * time the vocabulary has no clip for, the caller hears silence where * the time should be. + * + * **Narrowing this is not a free change**, and the direction matters. + * A daemon computes `requiredAssets` from *its own* copy of this + * constant, not from anything the version carries — so a device still on + * the quarter hour, handed a version published after this change, asks + * for `bktime_0915`, does not find it, and refuses to arm the flow at + * all. The safe order is: platforms narrow what they *offer* first + * (leaving the frozen set a superset), fleets update, and only then does + * this move. See the platform's docs/34 §2. */ -export const BOOK_GRANULARITY_MINS = 15; +export const BOOK_GRANULARITY_MINS = 30; // ── Vocabulary refs ────────────────────────────────────────────────────── @@ -198,7 +207,7 @@ function minutesOf(hhmm: string): number | null { } /** - * The starts a single open range can produce: on the quarter-hour grid, + * The starts a single open range can produce: on the half-hour grid, * from the first grid point at or after `open`, while the whole * appointment still finishes by `close`. * diff --git a/packages/flow-schema/test/book.test.ts b/packages/flow-schema/test/book.test.ts index f2ffe4b..2f2bcbe 100644 --- a/packages/flow-schema/test/book.test.ts +++ b/packages/flow-schema/test/book.test.ts @@ -69,25 +69,23 @@ describe('bookVocabularyRefs', () => { expect(bookVocabularyRefs(bookNode())).toContain('bkpress_3'); }); - it('puts starts on the quarter hour and leaves room for the whole appointment', () => { + it('puts starts on the half hour and leaves room for the whole appointment', () => { // 09:00–11:00, 30-minute appointments: the last start that still // finishes by close is 10:30. expect(times(bookNode())).toEqual([ 'bktime_0900', - 'bktime_0915', 'bktime_0930', - 'bktime_0945', 'bktime_1000', - 'bktime_1015', 'bktime_1030', ]); }); - it('starts at the first quarter hour at or after opening', () => { - // 09:10 opens onto a 09:15 grid; 09:45 is dropped because a - // 30-minute appointment starting there runs past the 10:10 close. - const node = bookNode({ schedule: { tue: [{ open: '09:10', close: '10:10' }] } }); - expect(times(node)).toEqual(['bktime_0915', 'bktime_0930']); + it('starts at the first half hour at or after opening', () => { + // 09:10 opens onto a 09:30 grid — never 09:10, and no longer 09:15. + // 10:30 is dropped because a 30-minute appointment starting there + // runs past the 10:40 close. + const node = bookNode({ schedule: { tue: [{ open: '09:10', close: '10:40' }] } }); + expect(times(node)).toEqual(['bktime_0930', 'bktime_1000']); }); it('is the union over every day and every window, without duplicates', () => { @@ -103,10 +101,8 @@ describe('bookVocabularyRefs', () => { }); expect(times(node)).toEqual([ 'bktime_0900', - 'bktime_0915', 'bktime_0930', 'bktime_1400', - 'bktime_1415', 'bktime_1430', ]); }); From 85095e947fa645f455c86f2aed5b7d00013f5ee8 Mon Sep 17 00:00:00 2001 From: Eason WaveKat Date: Sun, 9 Aug 2026 22:00:30 +1200 Subject: [PATCH 2/3] test(conformance): pin required assets in both languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `book` vocabulary is computed twice — once in TypeScript, once in Rust — from two copies of the same constant, and nothing compared them. Both languages' own tests pass happily while the two disagree, and the symptom of a disagreement is not a red build: it is a daemon refusing to arm a flow whose assets it cannot find, on whichever devices updated late. So the corpus gains an optional `requiredAssets` expectation, and both runners assert exact-set equality against it — exact, unlike the `errors` expectation beside it, because this set *is* the contract rather than a description of one. Only cases that pin a set take part, and the Rust side asserts at least one did, so a corpus that quietly stopped pinning cannot make the test pass by doing nothing. Verified by reverting the Rust constant to 15 on its own: the pin fails with "required assets differ for v2/valid/clinic", which is the drift it exists to catch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018stjkZJ4kxDViq1ENnVDmH --- conformance/v2/valid/clinic.expected.json | 33 ++++++++++++- crates/wavekat-flow/tests/conformance.rs | 46 +++++++++++++++++++ packages/flow-schema/test/conformance.test.ts | 42 ++++++++++++++++- 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/conformance/v2/valid/clinic.expected.json b/conformance/v2/valid/clinic.expected.json index da10c64..2af81e3 100644 --- a/conformance/v2/valid/clinic.expected.json +++ b/conformance/v2/valid/clinic.expected.json @@ -1,5 +1,36 @@ { "description": "The worked `book` example: a full booking flow with split opening hours, holiday exceptions, a buffer, and all four exits wired — the two failure exits both landing on voicemail, which is the pattern that keeps a calendar outage from becoming a dead line.", "structurallyValid": true, - "semantic": { "ok": true, "errors": [] } + "semantic": { "ok": true, "errors": [] }, + "requiredAssets": [ + "bkday_fri", + "bkday_mon", + "bkday_sat", + "bkday_sun", + "bkday_thu", + "bkday_today", + "bkday_tomorrow", + "bkday_tue", + "bkday_wed", + "bkpress_1", + "bkpress_2", + "bkpress_3", + "bktaken", + "bktime_0900", + "bktime_0930", + "bktime_1000", + "bktime_1030", + "bktime_1100", + "bktime_1130", + "bktime_1200", + "bktime_1230", + "bktime_1300", + "bktime_1330", + "bktime_1400", + "bktime_1430", + "bktime_1500", + "bktime_1530", + "bktime_1600", + "bktime_1630" + ] } diff --git a/crates/wavekat-flow/tests/conformance.rs b/crates/wavekat-flow/tests/conformance.rs index 0a9dbbb..b17610c 100644 --- a/crates/wavekat-flow/tests/conformance.rs +++ b/crates/wavekat-flow/tests/conformance.rs @@ -22,6 +22,10 @@ struct Expectation { #[serde(rename = "structurallyValid")] structurally_valid: bool, semantic: Semantic, + /// Optional: the exact asset set a daemon must have on disk before it + /// will arm this flow. See `corpus_required_assets_match_expectations`. + #[serde(rename = "requiredAssets", default)] + required_assets: Option>, } #[derive(serde::Deserialize)] @@ -169,3 +173,45 @@ fn corpus_semantic_matches_expectations() { } } } + +// The asset set, pinned across both languages. +// +// This is the one place a `book` node's vocabulary arithmetic is checked +// against something outside the language that computed it. Both sides derive +// the set from their own copy of `BOOK_GRANULARITY_MINS`, and a daemon +// refuses to arm a flow whose required assets are not all on disk — so if the +// two ever drift, the symptom is not a failing test but a customer's phone +// line going quiet, on whichever devices updated late. +// +// Exact-set equality, unlike the `errors` expectation above: this set *is* the +// contract, not a description of one, and a missing member is precisely the +// bug worth catching. +#[test] +fn corpus_required_assets_match_expectations() { + let mut pinned = 0usize; + for version in corpus_versions() { + for bucket in ["valid", "invalid"] { + for (stem, path) in yaml_cases(&version, bucket) { + let exp_path = corpus_dir(&version, bucket).join(format!("{stem}.expected.json")); + let exp: Expectation = + serde_json::from_str(&fs::read_to_string(exp_path).unwrap()).unwrap(); + let Some(expected) = exp.required_assets else { + continue; + }; + + let yaml = fs::read_to_string(&path).unwrap(); + let flow: Flow = serde_yaml_ng::from_str(&yaml) + .unwrap_or_else(|e| panic!("{version}/{bucket}/{stem} must parse: {e}")); + assert_eq!( + wavekat_flow::required_assets(&flow), + expected, + "required assets differ for {version}/{bucket}/{stem}" + ); + pinned += 1; + } + } + } + // A corpus that stopped pinning any would make this test pass by + // doing nothing, which is the one way it could fail silently. + assert!(pinned > 0, "no corpus case pins a required-asset set"); +} diff --git a/packages/flow-schema/test/conformance.test.ts b/packages/flow-schema/test/conformance.test.ts index d7db51a..a6cbb0b 100644 --- a/packages/flow-schema/test/conformance.test.ts +++ b/packages/flow-schema/test/conformance.test.ts @@ -18,7 +18,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { parse } from 'yaml'; -import { checkFlow } from '../src/index.js'; +import { checkFlow, requiredAssets } from '../src/index.js'; import { validateStructure } from '../src/structure.js'; const here = dirname(fileURLToPath(import.meta.url)); @@ -38,6 +38,10 @@ type Expectation = { * the deliberate cross-language divergence (unknown fields warn in TS, * are silently ignored by Rust serde; both still accept the document). */ tsWarnings?: string[]; + /** Optional: the exact asset set the daemon must have on disk before it + * will arm this flow. See the describe block below for why it is pinned + * here rather than in either language's own tests. */ + requiredAssets?: string[]; }; function casesIn(version: string, bucket: 'valid' | 'invalid'): string[] { @@ -104,3 +108,39 @@ describe.each(CORPUS_VERSIONS)('conformance corpus %s — semantic (checkFlow)', } } }); + +// The asset set, pinned across both languages. +// +// This is the one place a `book` node's vocabulary arithmetic is checked +// against something outside the language that computed it. Both sides +// derive the set from their own copy of `BOOK_GRANULARITY_MINS`, and a +// daemon refuses to arm a flow whose required assets are not all on disk +// — so if the two constants ever drift, the symptom is not a failing +// test but a customer's phone line going quiet, on the devices that +// happened to update late. +// +// Exact-set equality, unlike the error expectations above: this set is +// the contract itself, not a description of one, and a missing member is +// exactly the bug worth catching. +// Only cases that pin a set: an `invalid` document has no assets to +// speak of, and a flow with no `book` node has nothing that could drift. +// Collected before `describe`, because a version whose corpus pins +// nothing must produce no suite rather than an empty one. +const ASSET_CASES = CORPUS_VERSIONS.map((version) => ({ + version, + cases: (['valid', 'invalid'] as const).flatMap((bucket) => + casesIn(version, bucket) + .map((name) => ({ bucket, name, expected: expectation(version, bucket, name) })) + .filter((entry) => entry.expected.requiredAssets !== undefined), + ), +})).filter((entry) => entry.cases.length > 0); + +describe.each(ASSET_CASES)('conformance corpus $version — required assets', ({ cases, version }) => { + for (const { bucket, name, expected } of cases) { + it(`${bucket}/${name}`, () => { + const result = checkFlow(source(version, bucket, name)); + expect(result.flow, `${bucket}/${name} must parse to pin its assets`).toBeDefined(); + expect(requiredAssets(result.flow!)).toEqual(expected.requiredAssets); + }); + } +}); From 378009379ebcc324ff86e8eb11d6f20be4b993cd Mon Sep 17 00:00:00 2001 From: Eason WaveKat Date: Mon, 10 Aug 2026 20:00:32 +1200 Subject: [PATCH 3/3] docs: the platform spec is docs/35 now Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018stjkZJ4kxDViq1ENnVDmH --- packages/flow-schema/src/book.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flow-schema/src/book.ts b/packages/flow-schema/src/book.ts index 421c461..cd8d20b 100644 --- a/packages/flow-schema/src/book.ts +++ b/packages/flow-schema/src/book.ts @@ -98,7 +98,7 @@ export const MAX_BOOK_OFFERS = 5; * for `bktime_0915`, does not find it, and refuses to arm the flow at * all. The safe order is: platforms narrow what they *offer* first * (leaving the frozen set a superset), fleets update, and only then does - * this move. See the platform's docs/34 §2. + * this move. See the platform's docs/35 §2. */ export const BOOK_GRANULARITY_MINS = 30;