From ade746155dc7d753a7f523e0fcfa61d07c4f85d4 Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:47:24 -0700 Subject: [PATCH 1/3] docs: record canonical IOA parser decision (ARN-214) --- docs/adrs/0171-canonical-ioa-schema-parser.md | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/adrs/0171-canonical-ioa-schema-parser.md diff --git a/docs/adrs/0171-canonical-ioa-schema-parser.md b/docs/adrs/0171-canonical-ioa-schema-parser.md new file mode 100644 index 000000000..ce47f46c4 --- /dev/null +++ b/docs/adrs/0171-canonical-ioa-schema-parser.md @@ -0,0 +1,121 @@ +# ADR-0171: Canonical schema-backed IOA parsing + +- Status: Accepted +- Date: 2026-07-14 +- Deciders: Temper core maintainers +- Related: + - ADR-0040: Composite actions + - ADR-0041: IOA field invariants + - ADR-0046: Unified action triggers + - ADR-0049: State-entry timeouts + - `crates/temper-spec/src/automaton/types.rs` + - `crates/temper-spec/src/automaton/parser.rs` + +## Context + +IOA source currently takes two incompatible parsing paths. A hand-written line parser +constructs the core automaton while a collection of Serde helpers reparses isolated +sections for triggers, composite metadata, field invariants, timeouts, keys, vectors, +admission control, and webhooks. The line parser ignores unknown lines and fields and +drops unnamed state, action, invariant, liveness, and integration blocks. The webhook +helper additionally converts any parse error into an empty list. Later verification +cannot tell whether a declaration was absent or discarded. + +The complete IOA data model already implements Serde deserialization. Maintaining a +second grammar in line-oriented code therefore adds loss without supplying a separate +capability. It has also caused modeled declarations such as `[[context_entity]]` to be +absent from the public `parse_automaton` result. + +## Decision + +### Parse the complete document exactly once + +`temper-spec` will deserialize the entire source directly into the canonical +`Automaton` schema with `toml::from_str`. Section isolation and parse-again extractors +are removed. TOML syntax and duplicate-key failures retain the source spans reported by +the TOML deserializer. + +**Why this approach**: one schema and one parse result make declaration consumption +structural. A supported declaration is represented in the AST once; malformed source +cannot be converted into an apparently valid partial automaton. + +### Reject unknown schema content at its owning boundary + +Closed IOA records and tagged variants use Serde's unknown-field rejection. This +includes automaton metadata, states, actions, safety and liveness declarations, +webhooks, trigger metadata, timeouts, keys, vectors, admission control, guards, and +effects. Required fields remain required, so truncated or unnamed declarations fail +during deserialization instead of disappearing. + +The legacy `[[integration]]` record remains the deliberate extension point: keys not +owned by its fixed fields populate its existing string configuration map. Nested maps +such as trigger headers and parameters likewise retain arbitrary user-defined keys. + +### Preserve supported source forms through schema deserialization + +String-form guards and effects are part of the accepted IOA language and remain +supported by field deserializers on `Action`. Structured arrays continue through the +typed `Guard` and `Effect` schemas. Existing effect aliases and string booleans remain +accepted where the current parser accepts them, but malformed values now return an +error rather than defaulting or being omitted. + +## Rollout Plan + +1. Replace the parser and add regression coverage for unknown fields/tables, incomplete + blocks, malformed webhooks, duplicate keys, the valid repository corpus, and a + canonical serialize/parse round trip. +2. Run the full verification and workspace suites before deployment. Specs rejected by + the stricter boundary must be corrected at their source; there is no permissive mode. + +## Readiness Gates + +- Every repository IOA fixture parses through the canonical public parser. +- Malformed or unsupported source fails before validation or runtime table generation. +- The public parser retains all declarations represented by the canonical schema. + +## Consequences + +### Positive + +- Verification can no longer pass a partial AST created by silent parser omission. +- New schema fields need one Serde model change rather than a model change plus parser + and extractor changes. +- Parallel parser and section-isolation code is deleted. + +### Negative + +- Previously ignored typos become deployment-blocking parse errors. +- Closed records require an explicit schema change before accepting new fields. + +### Risks + +- A historically accepted source form could be missed during migration. Repository-wide + corpus coverage and explicit compatibility deserializers mitigate this without + restoring permissive parsing. + +### DST Compliance + +- Parsing is pure and deterministic. This change is confined to `temper-spec` and does + not add clocks, randomness, concurrency, or simulation-visible I/O. + +## Non-Goals + +- Changing IOA runtime semantics or verification rules. +- Removing supported string-form guards/effects. +- Restricting intentional integration, header, parameter, or configuration maps. + +## Alternatives Considered + +1. **Add more checks to the line parser** — rejected because every schema addition would + still require synchronized grammars and isolated reparsing. +2. **Pre-scan for known text patterns before the current parser** — rejected because it + cannot prove structural consumption and creates a third grammar. +3. **Keep per-section Serde extraction but propagate all errors** — rejected because + unrelated declarations can still be omitted and cross-section duplicate/ownership + errors remain invisible. + +## Rollback Policy + +Reverting restores permissive partial parsing and is therefore not a safe operational +fallback. If a valid supported source form is missed, extend its typed deserializer and +add a corpus regression while retaining the single strict document parse. From ff66dfdda26c230a620d3447eabc3a82f55c8cba Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:13:09 -0700 Subject: [PATCH 2/3] test(spec): expose silent IOA parser omissions (ARN-214) --- .../src/automaton/parser_strictness_test.rs | 162 ++++++++++++++++++ .../temper-spec/src/automaton/parser_test.rs | 2 + crates/temper-spec/tests/ioa_corpus.rs | 68 ++++++++ 3 files changed, 232 insertions(+) create mode 100644 crates/temper-spec/src/automaton/parser_strictness_test.rs create mode 100644 crates/temper-spec/tests/ioa_corpus.rs diff --git a/crates/temper-spec/src/automaton/parser_strictness_test.rs b/crates/temper-spec/src/automaton/parser_strictness_test.rs new file mode 100644 index 000000000..a282e5173 --- /dev/null +++ b/crates/temper-spec/src/automaton/parser_strictness_test.rs @@ -0,0 +1,162 @@ +use super::super::{LivenessEnforcement, parse_automaton_with_liveness}; + +const BASE_SPEC: &str = r#" +[automaton] +name = "SafetyContract" +states = ["Ready"] +initial = "Ready" +"#; + +fn parse(source: &str) -> Result { + parse_automaton_with_liveness(source, LivenessEnforcement::WarnOnly) +} + +fn assert_source_located_rejection(source: &str, expected: &str) { + let error = parse(source).expect_err("malformed safety content must be rejected"); + let message = error.to_string(); + assert!( + message.contains(expected), + "error must identify `{expected}`; got: {message}" + ); + assert!( + message.contains("line") && message.contains("column"), + "error must include a source location; got: {message}" + ); +} + +#[test] +fn rejects_unknown_top_level_safety_table() { + let source = format!( + r#"{BASE_SPEC} +[[saftey]] +name = "status_is_valid" +assert = "status \\in {{Ready}}" +"# + ); + + assert_source_located_rejection(&source, "saftey"); +} + +#[test] +fn rejects_unknown_invariant_field_instead_of_keeping_an_empty_assertion() { + let source = format!( + r#"{BASE_SPEC} +[[invariant]] +name = "status_is_valid" +asser = "status \\in {{Ready}}" +"# + ); + + assert_source_located_rejection(&source, "asser"); +} + +#[test] +fn rejects_truncated_safety_assignment() { + let source = format!( + r#"{BASE_SPEC} +[[invariant]] +name = "status_is_valid" +assert = +"# + ); + + assert_source_located_rejection(&source, "TOML"); +} + +#[test] +fn rejects_unnamed_core_declarations() { + let malformed_declarations = [ + ( + "action", + r#" +[[action]] +from = ["Ready"] +to = "Ready" +"#, + ), + ( + "invariant", + r#" +[[invariant]] +assert = "status \\in {Ready}" +"#, + ), + ( + "liveness", + r#" +[[liveness]] +from = ["Ready"] +has_actions = true +"#, + ), + ]; + + for (kind, declaration) in malformed_declarations { + let source = format!("{BASE_SPEC}{declaration}"); + let error = match parse(&source) { + Ok(_) => panic!("unnamed {kind} declaration must be rejected"), + Err(error) => error, + }; + let message = error.to_string(); + assert!( + message.contains("name"), + "unnamed {kind} error must identify the missing name; got: {message}" + ); + } +} + +#[test] +fn rejects_malformed_webhook_instead_of_silently_dropping_it() { + let source = format!( + r#"{BASE_SPEC} +[[webhook]] +name = "callback" +path = "callbacks/result" +method = "POST" +"# + ); + + assert_source_located_rejection(&source, "action"); +} + +#[test] +fn rejects_duplicate_safety_declaration_names() { + let source = format!( + r#"{BASE_SPEC} +[[invariant]] +name = "status_is_valid" +assert = "status \\in {{Ready}}" + +[[invariant]] +name = "status_is_valid" +assert = "status \\in {{Ready}}" +"# + ); + + let error = parse(&source).expect_err("duplicate invariant names must be rejected"); + let message = error.to_string(); + assert!(message.contains("invariant"), "got: {message}"); + assert!(message.contains("status_is_valid"), "got: {message}"); +} + +#[test] +fn retains_context_entities_across_canonical_round_trip() { + let source = format!( + r#"{BASE_SPEC} +[[context_entity]] +name = "workspace" +entity_type = "Workspace" +id_field = "workspace_id" +"# + ); + + let parsed = parse(&source).expect("valid context entity must parse"); + assert_eq!(parsed.context_entities.len(), 1); + assert_eq!(parsed.context_entities[0].name, "workspace"); + + let serialized = toml::to_string(&parsed).expect("canonical automaton must serialize"); + let reparsed = parse(&serialized).expect("serialized automaton must parse again"); + assert_eq!(reparsed.context_entities.len(), 1); + assert_eq!(reparsed.context_entities[0].entity_type, "Workspace"); + assert_eq!(reparsed.context_entities[0].id_field, "workspace_id"); +} diff --git a/crates/temper-spec/src/automaton/parser_test.rs b/crates/temper-spec/src/automaton/parser_test.rs index 392a7b910..e2f3686ee 100644 --- a/crates/temper-spec/src/automaton/parser_test.rs +++ b/crates/temper-spec/src/automaton/parser_test.rs @@ -6,6 +6,8 @@ mod core; mod features; #[path = "parser_integrations_test.rs"] mod integrations; +#[path = "parser_strictness_test.rs"] +mod strictness; #[path = "parser_triggers_test.rs"] mod triggers; diff --git a/crates/temper-spec/tests/ioa_corpus.rs b/crates/temper-spec/tests/ioa_corpus.rs new file mode 100644 index 000000000..c1a03d708 --- /dev/null +++ b/crates/temper-spec/tests/ioa_corpus.rs @@ -0,0 +1,68 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use temper_spec::automaton::{LivenessEnforcement, parse_automaton_with_liveness}; + +const SPEC_ROOTS: [&str; 6] = [ + "crates/temper-agents/specs", + "crates/temper-platform/src/specs", + "docs/examples/pipeline-specs", + "os-apps", + "reference-apps", + "test-fixtures/specs", +]; + +#[test] +fn every_tracked_ioa_spec_parses_through_the_canonical_schema() { + let workspace = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("temper-spec must live under /crates"); + let mut paths = Vec::new(); + for root in SPEC_ROOTS { + collect_ioa_specs(&workspace.join(root), &mut paths); + } + paths.sort(); + paths.dedup(); + assert!(paths.len() >= 100, "expected the repository IOA corpus"); + + let mut failures = Vec::new(); + for path in paths { + let source = fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + if let Err(error) = parse_automaton_with_liveness(&source, LivenessEnforcement::WarnOnly) { + let relative = path.strip_prefix(workspace).unwrap_or(&path); + failures.push(format!("{}: {error}", relative.display())); + } + } + + assert!( + failures.is_empty(), + "repository IOA corpus failures:\n{}", + failures.join("\n") + ); +} + +fn collect_ioa_specs(root: &Path, paths: &mut Vec) { + let mut entries = fs::read_dir(root) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", root.display())) + .map(|entry| entry.expect("directory entry must be readable")) + .collect::>(); + entries.sort_by_key(|entry| entry.path()); + + for entry in entries { + let path = entry.path(); + let file_type = entry + .file_type() + .unwrap_or_else(|error| panic!("failed to stat {}: {error}", path.display())); + if file_type.is_dir() { + collect_ioa_specs(&path, paths); + } else if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".ioa.toml")) + { + paths.push(path); + } + } +} From e31b6c406941d6f60e18fa7972792c2152b8a246 Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:54:35 -0700 Subject: [PATCH 3/3] fix(spec): reject malformed IOA safety content (ARN-214) --- .claude/hooks/pre-commit.sh | 2 +- crates/temper-agents/specs/agent.ioa.toml | 26 +- crates/temper-agents/specs/process.ioa.toml | 26 +- .../src/specs/GovernanceDecision.ioa.toml | 5 +- crates/temper-server/tests/dst_hotswap.rs | 4 +- crates/temper-server/tests/e2e_gepa_loop.rs | 125 +-- .../src/automaton/field_invariant.rs | 1 + crates/temper-spec/src/automaton/parser.rs | 152 +++- .../src/automaton/parser_core_test.rs | 16 + .../src/automaton/parser_features_test.rs | 18 +- .../src/automaton/parser_integrations_test.rs | 16 + .../src/automaton/parser_strictness_test.rs | 30 + .../src/automaton/parser_triggers_test.rs | 160 ++++ .../automaton/toml_parser/compatibility.rs | 358 ++++++++ .../src/automaton/toml_parser/effects.rs | 200 ----- .../src/automaton/toml_parser/guards.rs | 254 ------ .../src/automaton/toml_parser/inline.rs | 204 ----- .../src/automaton/toml_parser/mod.rs | 796 +----------------- .../src/automaton/toml_parser/tests.rs | 476 ----------- crates/temper-spec/src/automaton/types.rs | 46 +- .../src/automaton/types/deserialization.rs | 223 +++++ crates/temper-spec/src/bin/verify_specs.rs | 41 +- crates/temper-spec/tests/ioa_corpus.rs | 49 +- .../tests/migration_differential.rs | 32 +- docs/adrs/0171-canonical-ioa-schema-parser.md | 12 +- .../temper-agent/specs/temper_agent.ioa.toml | 5 - scripts/setup-hooks.sh | 9 +- test-fixtures/specs/process.ioa.toml | 26 +- 28 files changed, 1259 insertions(+), 2053 deletions(-) create mode 100644 crates/temper-spec/src/automaton/toml_parser/compatibility.rs delete mode 100644 crates/temper-spec/src/automaton/toml_parser/effects.rs delete mode 100644 crates/temper-spec/src/automaton/toml_parser/guards.rs delete mode 100644 crates/temper-spec/src/automaton/toml_parser/inline.rs delete mode 100644 crates/temper-spec/src/automaton/toml_parser/tests.rs create mode 100644 crates/temper-spec/src/automaton/types/deserialization.rs diff --git a/.claude/hooks/pre-commit.sh b/.claude/hooks/pre-commit.sh index f52334388..f3b140d09 100755 --- a/.claude/hooks/pre-commit.sh +++ b/.claude/hooks/pre-commit.sh @@ -69,7 +69,7 @@ if [ -n "$STAGED_SPECS" ]; then for SPEC in $STAGED_SPECS; do # Try parsing the spec (syntax check only, not full cascade) - if ! cargo run -p temper-cli --quiet -- verify --specs-dir "$(dirname "$SPEC")" 2>/dev/null; then + if ! cargo run -p temper-spec --quiet --bin verify_specs -- --syntax-only "$SPEC" 2>/dev/null; then echo "BLOCKED: Spec syntax error in $SPEC" >&2 echo "Fix the spec before committing." >&2 exit 1 diff --git a/crates/temper-agents/specs/agent.ioa.toml b/crates/temper-agents/specs/agent.ioa.toml index 7da6ac0e3..ca0fc8829 100644 --- a/crates/temper-agents/specs/agent.ioa.toml +++ b/crates/temper-agents/specs/agent.ioa.toml @@ -11,12 +11,6 @@ states = [ ] initial = "Created" -[automaton.timeouts] -BlockedInference = "120s" -BlockedToolCall = "60s" -BlockedCompaction = "30s" -BlockedApproval = "3600s" - [[state]] name = "turns" type = "counter" @@ -181,6 +175,26 @@ kind = "input" from = ["BlockedInference", "BlockedToolCall", "BlockedApproval", "BlockedCompaction"] to = "Failed" +[[state_timeout]] +state = "BlockedInference" +after_seconds = 120 +on_timeout = "TimeoutExpired" + +[[state_timeout]] +state = "BlockedToolCall" +after_seconds = 60 +on_timeout = "TimeoutExpired" + +[[state_timeout]] +state = "BlockedCompaction" +after_seconds = 30 +on_timeout = "TimeoutExpired" + +[[state_timeout]] +state = "BlockedApproval" +after_seconds = 3600 +on_timeout = "TimeoutExpired" + # ─── Integrations ───────────────────────────────────────────────────────────── [[integration]] diff --git a/crates/temper-agents/specs/process.ioa.toml b/crates/temper-agents/specs/process.ioa.toml index ffc141ee3..183dfc7b8 100644 --- a/crates/temper-agents/specs/process.ioa.toml +++ b/crates/temper-agents/specs/process.ioa.toml @@ -11,12 +11,6 @@ states = [ ] initial = "Created" -[automaton.timeouts] -BlockedInference = "120s" -BlockedToolCall = "60s" -BlockedCompaction = "30s" -BlockedApproval = "3600s" - [[state]] name = "turns" type = "counter" @@ -192,6 +186,26 @@ from = ["BlockedInference", "BlockedToolCall", "BlockedApproval", "BlockedCompac to = "Failed" effect = [{ type = "trigger", name = "notify_parent" }] +[[state_timeout]] +state = "BlockedInference" +after_seconds = 120 +on_timeout = "TimeoutExpired" + +[[state_timeout]] +state = "BlockedToolCall" +after_seconds = 60 +on_timeout = "TimeoutExpired" + +[[state_timeout]] +state = "BlockedCompaction" +after_seconds = 30 +on_timeout = "TimeoutExpired" + +[[state_timeout]] +state = "BlockedApproval" +after_seconds = 3600 +on_timeout = "TimeoutExpired" + # ─── Integrations ───────────────────────────────────────────────────────────── [[integration]] diff --git a/crates/temper-platform/src/specs/GovernanceDecision.ioa.toml b/crates/temper-platform/src/specs/GovernanceDecision.ioa.toml index 7ca54ecb7..5198337fa 100644 --- a/crates/temper-platform/src/specs/GovernanceDecision.ioa.toml +++ b/crates/temper-platform/src/specs/GovernanceDecision.ioa.toml @@ -105,7 +105,10 @@ from = ["Pending"] to = "Approved" kind = "input" params = ["decided_by", "scope", "generated_policy"] -effect = '[{ type = "trigger", name = "GenerateCedarPolicy" }, { type = "trigger", name = "DispatchCallback" }]' +effect = [ + { type = "trigger", name = "GenerateCedarPolicy" }, + { type = "trigger", name = "DispatchCallback" }, +] [[action]] name = "Deny" diff --git a/crates/temper-server/tests/dst_hotswap.rs b/crates/temper-server/tests/dst_hotswap.rs index 247f484b9..9ae911514 100644 --- a/crates/temper-server/tests/dst_hotswap.rs +++ b/crates/temper-server/tests/dst_hotswap.rs @@ -24,7 +24,7 @@ to = "Draft" kind = "input" [[action.effect]] -type = "IncrementCounter" +type = "increment" var = "item_count" [[action]] @@ -34,7 +34,7 @@ to = "Submitted" kind = "input" [[action.guard]] -type = "CounterMin" +type = "min_count" var = "item_count" min = 1 diff --git a/crates/temper-server/tests/e2e_gepa_loop.rs b/crates/temper-server/tests/e2e_gepa_loop.rs index d7f97ed28..146823c42 100644 --- a/crates/temper-server/tests/e2e_gepa_loop.rs +++ b/crates/temper-server/tests/e2e_gepa_loop.rs @@ -3,7 +3,7 @@ //! //! Proves the full GEPA cycle works by: //! 1. Installing PM skill on a test tenant -//! 2. Simulating agent failures (Reassign action doesn't exist on Issue) +//! 2. Simulating agent failures (TransferAssignment action doesn't exist on Issue) //! 3. Running sentinel check → ots_trajectory_failure_cluster fires //! 4. Creating EvolutionRun entity, driving it through the full state machine //! 5. Using GEPA primitives (replay, scoring, Pareto frontier) on the mutation @@ -251,7 +251,7 @@ async fn e2e_gepa_sentinel_detects_failure_cluster() { .expect("PM skill should install"); assert!(types.contains(&"Issue".to_string())); - // Attempt "Reassign" on Issue — this action doesn't exist in the spec. + // Attempt "TransferAssignment" on Issue — this action doesn't exist in the spec. // Each attempt should fail and be recorded in the trajectory log. let mut failure_count = 0; for i in 0..6 { @@ -260,13 +260,16 @@ async fn e2e_gepa_sentinel_detects_failure_cluster() { TENANT, "Issue", &format!("issue-{i}"), - "Reassign", + "TransferAssignment", serde_json::json!({"NewAssigneeId": "agent-2"}), ) .await; match r { Ok(resp) => { - assert!(!resp.success, "Reassign should fail — action not in spec"); + assert!( + !resp.success, + "TransferAssignment should fail — action not in spec" + ); failure_count += 1; } Err(_) => { @@ -275,7 +278,10 @@ async fn e2e_gepa_sentinel_detects_failure_cluster() { } } } - assert_eq!(failure_count, 6, "Should have 6 failed Reassign attempts"); + assert_eq!( + failure_count, 6, + "Should have 6 failed TransferAssignment attempts" + ); // Build trajectory entries matching what the server would record. let trajectory_entries: Vec = (0..6) @@ -284,7 +290,7 @@ async fn e2e_gepa_sentinel_detects_failure_cluster() { tenant: TENANT.to_string(), entity_type: "Issue".to_string(), entity_id: format!("issue-{i}"), - action: "Reassign".to_string(), + action: "TransferAssignment".to_string(), success: false, from_status: Some("Backlog".to_string()), to_status: None, @@ -411,7 +417,7 @@ async fn e2e_gepa_evolution_run_full_lifecycle() { evo_id, "RecordDataset", serde_json::json!({ - "DatasetJson": "{\"triplets\":[{\"input\":\"Reassign\",\"output\":\"error\",\"feedback\":\"add action\"}]}" + "DatasetJson": "{\"triplets\":[{\"input\":\"TransferAssignment\",\"output\":\"error\",\"feedback\":\"add action\"}]}" }), ) .await @@ -427,8 +433,8 @@ async fn e2e_gepa_evolution_run_full_lifecycle() { evo_id, "RecordMutation", serde_json::json!({ - "MutatedSpecSource": "mutated spec with Reassign", - "MutationSummary": "Added Reassign action to Issue" + "MutatedSpecSource": "mutated spec with TransferAssignment", + "MutationSummary": "Added TransferAssignment action to Issue" }), ) .await @@ -668,7 +674,7 @@ async fn e2e_gepa_sentinel_monitor_lifecycle() { sentinel_id, "AlertsFound", serde_json::json!({ - "AlertDetails": "6 Reassign failures on Issue", + "AlertDetails": "6 TransferAssignment failures on Issue", "SuggestedTarget": "project-management/Issue" }), ) @@ -733,15 +739,15 @@ async fn e2e_gepa_sentinel_monitor_lifecycle() { async fn e2e_gepa_algorithm_primitives_integrated() { use temper_evolution::gepa::*; - // --- Step 1: Build replay results for original spec (missing Reassign) --- + // --- Step 1: Build replay results for original spec (missing TransferAssignment) --- let mut replay_original = ReplayResult::new(); // 5 successful actions. for _ in 0..5 { replay_original.record_success(); } - // 5 failures — Reassign not found. + // 5 failures — TransferAssignment not found. for _ in 0..5 { - replay_original.record_unknown_action("Reassign", "InProgress"); + replay_original.record_unknown_action("TransferAssignment", "InProgress"); } assert_eq!(replay_original.actions_attempted, 10); assert_eq!(replay_original.succeeded, 5); @@ -786,14 +792,14 @@ async fn e2e_gepa_algorithm_primitives_integrated() { ); for i in 0..5 { let triplet = ReflectiveTriplet::new( - format!("Agent attempted Reassign on issue-{i} in InProgress state"), - "Error: action 'Reassign' not found in spec".into(), - "Add Reassign action: from=[InProgress] to=InProgress, with guard requiring assignee_set".into(), + format!("Agent attempted TransferAssignment on issue-{i} in InProgress state"), + "Error: action 'TransferAssignment' not found in spec".into(), + "Add TransferAssignment action: from=[InProgress] to=InProgress, with guard requiring assignee_set".into(), 0.0, format!("traj-{i}"), ) .with_entity_type("Issue".into()) - .with_action("Reassign".into()); + .with_action("TransferAssignment".into()); dataset.add_triplet(triplet); } @@ -801,12 +807,12 @@ async fn e2e_gepa_algorithm_primitives_integrated() { assert_eq!(dataset.success_count(), 0); let llm_prompt = dataset.format_for_llm(); - assert!(llm_prompt.contains("Reassign")); + assert!(llm_prompt.contains("TransferAssignment")); assert!(llm_prompt.contains("5 failures")); - // --- Step 6: Simulate mutation — "LLM" proposes spec with Reassign --- + // --- Step 6: Simulate mutation — "LLM" proposes spec with TransferAssignment --- let mut replay_mutated = ReplayResult::new(); - // All 10 actions now succeed (including the 5 Reassigns). + // All 10 actions now succeed (including the 5 TransferAssignment attempts). for _ in 0..10 { replay_mutated.record_success(); } @@ -827,14 +833,14 @@ async fn e2e_gepa_algorithm_primitives_integrated() { // --- Step 8: Mutated candidate dominates original --- let mut candidate_mutated = Candidate::new( "c1".into(), - "mutated issue spec with Reassign".into(), + "mutated issue spec with TransferAssignment".into(), "project-management".into(), "Issue".into(), 1, now, ) .with_parent("c0".into()) - .with_mutation_summary("Added Reassign action from InProgress to InProgress".into()); + .with_mutation_summary("Added TransferAssignment action from InProgress to InProgress".into()); for (obj, score) in scores_mutated.into_map() { candidate_mutated.set_score(obj, score); @@ -870,53 +876,53 @@ async fn e2e_gepa_algorithm_primitives_integrated() { } // ========================================================================= -// Phase 6: Hot-deploy mutated spec and verify Reassign works +// Phase 6: Hot-deploy mutated spec and verify TransferAssignment works // ========================================================================= -/// Proves: after hot-deploying a mutated Issue spec (with Reassign action), -/// the previously-failing Reassign action now succeeds through the platform. +/// Proves: after hot-deploying a mutated Issue spec (with TransferAssignment action), +/// the previously-failing TransferAssignment action now succeeds through the platform. #[tokio::test] async fn e2e_gepa_hotdeploy_and_verify() { let (_guard, _clock, _id_gen) = install_deterministic_context(46); let harness = SimPlatformHarness::no_faults(46); - // Install PM skill (Issue spec WITHOUT Reassign). + // Install PM skill (Issue spec WITHOUT TransferAssignment). harness .install_app(TENANT, "project-management") .await .expect("PM skill should install"); - // Verify Reassign fails on a fresh Issue. + // Verify TransferAssignment fails on a fresh Issue. let r = harness .dispatch( TENANT, "Issue", "issue-hotdeploy-1", - "Reassign", + "TransferAssignment", serde_json::json!({"NewAssigneeId": "agent-2"}), ) .await; if let Ok(resp) = &r { assert!( !resp.success, - "Reassign should fail before hot-deploy: {:?}", + "TransferAssignment should fail before hot-deploy: {:?}", resp.error ); } - // Now create a mutated Issue spec that adds Reassign. - // We take the original and add a Reassign action. + // Now create a mutated Issue spec that adds TransferAssignment. + // We take the original and add a TransferAssignment action. let mutated_issue_spec = include_str!("../../../os-apps/project-management/specs/issue.ioa.toml").to_string() + r#" [[action]] -name = "Reassign" +name = "TransferAssignment" kind = "input" from = ["Backlog", "Triage", "Todo", "InProgress", "InReview", "Planning", "Planned"] guard = "is_true assignee_set" params = ["NewAssigneeId"] -hint = "Reassign the issue to a different implementer." +hint = "Transfer the issue to a different implementer." "#; // Verify the mutated spec parses (L0 check). @@ -952,7 +958,7 @@ hint = "Reassign the issue to a different implementer." .expect("hot-deploy should succeed"); } - // Now Reassign should work on an Issue that has an assignee set. + // Now TransferAssignment should work on an Issue that has an assignee set. // Create a fresh Issue (starts in Backlog), then Assign to set assignee_set=true. let r = harness .dispatch( @@ -966,26 +972,26 @@ hint = "Reassign the issue to a different implementer." .expect("Assign should succeed"); assert!(r.success, "Assign failed: {:?}", r.error); - // NOW: Reassign should succeed because the mutated spec has it + // NOW: TransferAssignment should succeed because the mutated spec has it // (self-loop on Backlog with guard is_true assignee_set). let r = harness .dispatch( TENANT, "Issue", "issue-hotdeploy-2", - "Reassign", + "TransferAssignment", serde_json::json!({"NewAssigneeId": "agent-2"}), ) .await - .expect("Reassign should succeed after hot-deploy"); + .expect("TransferAssignment should succeed after hot-deploy"); assert!( r.success, - "Reassign should succeed after hot-deploy: {:?}", + "TransferAssignment should succeed after hot-deploy: {:?}", r.error ); assert_eq!( r.state.status, "Backlog", - "Reassign is a self-loop, issue stays in Backlog" + "TransferAssignment is a self-loop, issue stays in Backlog" ); } @@ -1011,18 +1017,18 @@ async fn e2e_gepa_full_loop() { .expect("evolution skill should install"); harness.register_inline_spec(TENANT, "EvolutionRun", EVOLUTION_RUN_IOA_NO_INTEGRATIONS); - // --- Step 2: Simulate 6 Reassign failures --- + // --- Step 2: Simulate 6 TransferAssignment failures --- for i in 0..6 { let _r = harness .dispatch( TENANT, "Issue", &format!("loop-issue-{i}"), - "Reassign", + "TransferAssignment", serde_json::json!({"NewAssigneeId": "agent-x"}), ) .await; - // All should fail — Reassign doesn't exist. + // All should fail — TransferAssignment doesn't exist. } // --- Step 3: Sentinel detects the cluster --- @@ -1032,7 +1038,7 @@ async fn e2e_gepa_full_loop() { tenant: TENANT.to_string(), entity_type: "Issue".to_string(), entity_id: format!("loop-issue-{i}"), - action: "Reassign".to_string(), + action: "TransferAssignment".to_string(), success: false, from_status: Some("Backlog".to_string()), to_status: None, @@ -1084,7 +1090,7 @@ async fn e2e_gepa_full_loop() { "s1", "AlertsFound", serde_json::json!({ - "AlertDetails": "6 Reassign failures on Issue", + "AlertDetails": "6 TransferAssignment failures on Issue", "SuggestedTarget": "project-management/Issue" }), ) @@ -1130,7 +1136,10 @@ async fn e2e_gepa_full_loop() { ), ( "RecordMutation", - serde_json::json!({"MutatedSpecSource": "spec with Reassign", "MutationSummary": "Added Reassign"}), + serde_json::json!({ + "MutatedSpecSource": "spec with TransferAssignment", + "MutationSummary": "Added TransferAssignment" + }), ), ( "RecordVerificationPass", @@ -1173,12 +1182,12 @@ async fn e2e_gepa_full_loop() { + r#" [[action]] -name = "Reassign" +name = "TransferAssignment" kind = "input" from = ["Backlog", "Triage", "Todo", "InProgress", "InReview", "Planning", "Planned"] guard = "is_true assignee_set" params = ["NewAssigneeId"] -hint = "Reassign the issue to a different implementer." +hint = "Transfer the issue to a different implementer." "#; { @@ -1218,8 +1227,8 @@ hint = "Reassign the issue to a different implementer." assert!(r.success); assert_eq!(r.state.status, "Completed"); - // --- Step 7: Replay — Reassign now succeeds --- - // Create a fresh issue, Assign to set assignee_set=true, then Reassign. + // --- Step 7: Replay — TransferAssignment now succeeds --- + // Create a fresh issue, Assign to set assignee_set=true, then TransferAssignment. let r = harness .dispatch( TENANT, @@ -1232,32 +1241,32 @@ hint = "Reassign the issue to a different implementer." .unwrap(); assert!(r.success, "Assign failed: {:?}", r.error); - // The moment of truth: Reassign should NOW succeed after evolution hot-deploy. + // The moment of truth: TransferAssignment should NOW succeed after evolution hot-deploy. let r = harness .dispatch( TENANT, "Issue", "loop-retry-1", - "Reassign", + "TransferAssignment", serde_json::json!({"NewAssigneeId": "agent-2"}), ) .await - .expect("Reassign should succeed after evolution hot-deploy"); + .expect("TransferAssignment should succeed after evolution hot-deploy"); assert!( r.success, - "Reassign MUST succeed after GEPA evolution and hot-deploy: {:?}", + "TransferAssignment MUST succeed after GEPA evolution and hot-deploy: {:?}", r.error ); assert_eq!( r.state.status, "Backlog", - "Reassign self-loop keeps Backlog" + "TransferAssignment self-loop keeps Backlog" ); // --- Step 8: Verify GEPA primitives agree --- use temper_evolution::gepa::*; let mut replay = ReplayResult::new(); - // All 5 Reassign attempts now succeed. + // All 5 TransferAssignment attempts now succeed. for _ in 0..5 { replay.record_success(); } @@ -1383,7 +1392,7 @@ to = "Done" let trajectory_actions = serde_json::json!([ {"action": "StartWork", "params": {}}, {"action": "Complete", "params": {}}, - {"action": "Reassign", "params": {"NewAssigneeId": "agent-x"}} + {"action": "TransferAssignment", "params": {"NewAssigneeId": "agent-x"}} ]); let r = state @@ -1530,7 +1539,7 @@ fn e2e_gepa_full_autonomous_loop_with_adapter() { # In production, Claude reads the reflective dataset (failure traces) and # proposes a minimal IOA spec edit. Here we return a deterministic mutation. cat <<'MOCK_OUTPUT' -{{"MutatedSpecSource": "[automaton]\nname = \"TestIssue\"\nstates = [\"Backlog\", \"InProgress\", \"Done\"]\ninitial = \"Backlog\"\n\n[[action]]\nname = \"StartWork\"\nkind = \"input\"\nfrom = [\"Backlog\"]\nto = \"InProgress\"\n\n[[action]]\nname = \"Complete\"\nkind = \"input\"\nfrom = [\"InProgress\"]\nto = \"Done\"\n\n[[action]]\nname = \"Reassign\"\nkind = \"input\"\nfrom = [\"Backlog\", \"InProgress\"]\nto = \"InProgress\"\nparams = [\"NewAssigneeId\"]\n", "MutationSummary": "Added Reassign action to TestIssue spec based on trajectory failure analysis"}} +{{"MutatedSpecSource": "[automaton]\nname = \"TestIssue\"\nstates = [\"Backlog\", \"InProgress\", \"Done\"]\ninitial = \"Backlog\"\n\n[[action]]\nname = \"StartWork\"\nkind = \"input\"\nfrom = [\"Backlog\"]\nto = \"InProgress\"\n\n[[action]]\nname = \"Complete\"\nkind = \"input\"\nfrom = [\"InProgress\"]\nto = \"Done\"\n\n[[action]]\nname = \"TransferAssignment\"\nkind = \"input\"\nfrom = [\"Backlog\", \"InProgress\"]\nto = \"InProgress\"\nparams = [\"NewAssigneeId\"]\n", "MutationSummary": "Added TransferAssignment action to TestIssue spec based on trajectory failure analysis"}} MOCK_OUTPUT "# ) @@ -1679,7 +1688,7 @@ to = "Done" let trajectory_actions = serde_json::json!([ {"action": "StartWork", "params": {}}, {"action": "Complete", "params": {}}, - {"action": "Reassign", "params": {"NewAssigneeId": "agent-x"}} + {"action": "TransferAssignment", "params": {"NewAssigneeId": "agent-x"}} ]); let r = state diff --git a/crates/temper-spec/src/automaton/field_invariant.rs b/crates/temper-spec/src/automaton/field_invariant.rs index 6bff4fcd2..c8fbbd0c6 100644 --- a/crates/temper-spec/src/automaton/field_invariant.rs +++ b/crates/temper-spec/src/automaton/field_invariant.rs @@ -32,6 +32,7 @@ use serde_json::Value as Json; /// A single cross-field validation rule on one entity. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct FieldInvariant { /// Invariant name (used in error bodies and logs). pub name: String, diff --git a/crates/temper-spec/src/automaton/parser.rs b/crates/temper-spec/src/automaton/parser.rs index 9db406227..3da0f3c6b 100644 --- a/crates/temper-spec/src/automaton/parser.rs +++ b/crates/temper-spec/src/automaton/parser.rs @@ -3,8 +3,8 @@ //! Also provides conversion to the existing TemperModel and TransitionTable //! formats, so the verification cascade and runtime work unchanged. //! -//! The hand-rolled TOML parser lives in [`super::toml_parser`] to keep this -//! module focused on the public API and validation logic. +//! The canonical schema-backed TOML parser lives in [`super::toml_parser`] so +//! this module can remain focused on the public API and validation logic. use super::toml_parser; use super::types::*; @@ -276,7 +276,32 @@ fn expand_external_action_triggers(automaton: &mut Automaton) -> Result<(), Auto } } } - automaton.integrations.extend(synthesized); + for candidate in synthesized { + let mut matching_count = 0; + let mut identical_count = 0; + for integration in &automaton.integrations { + if integration.name == candidate.name || integration.trigger == candidate.trigger { + matching_count += 1; + if integration == &candidate { + identical_count += 1; + } + } + } + match (matching_count, identical_count) { + (1, 1) => { + // Canonical serialization includes synthesized integrations. + // Treat the identical record as already expanded so a + // parse/serialize/parse round trip is idempotent. + } + (0, 0) => automaton.integrations.push(candidate), + _ => { + return Err(AutomatonParseError::Validation(format!( + "integration name or trigger conflicts with the canonical action-trigger record '{}'", + candidate.trigger + ))); + } + } + } Ok(()) } @@ -526,7 +551,121 @@ fn format_effects(effects: &[Effect]) -> String { .join(" /\\ ") } +fn validate_unique_names<'a, I>(kind: &str, names: I) -> Result<(), AutomatonParseError> +where + I: IntoIterator, +{ + let mut seen = std::collections::BTreeSet::new(); + for name in names { + if name.trim().is_empty() { + return Err(AutomatonParseError::Validation(format!( + "{kind} name must not be empty" + ))); + } + if !seen.insert(name) { + return Err(AutomatonParseError::Validation(format!( + "{kind} '{name}' declared twice" + ))); + } + } + Ok(()) +} + fn validate(automaton: &Automaton) -> Result<(), AutomatonParseError> { + if automaton.automaton.name.trim().is_empty() { + return Err(AutomatonParseError::Validation( + "automaton name must not be empty".to_string(), + )); + } + if automaton.automaton.states.is_empty() { + return Err(AutomatonParseError::Validation( + "automaton must declare at least one state".to_string(), + )); + } + + validate_unique_names( + "automaton state", + automaton.automaton.states.iter().map(String::as_str), + )?; + validate_unique_names( + "state variable", + automaton.state.iter().map(|state| state.name.as_str()), + )?; + validate_unique_names( + "action", + automaton.actions.iter().map(|action| action.name.as_str()), + )?; + validate_unique_names( + "invariant", + automaton + .invariants + .iter() + .map(|invariant| invariant.name.as_str()), + )?; + validate_unique_names( + "liveness property", + automaton + .liveness + .iter() + .map(|property| property.name.as_str()), + )?; + validate_unique_names( + "integration", + automaton + .integrations + .iter() + .map(|integration| integration.name.as_str()), + )?; + validate_unique_names( + "webhook", + automaton + .webhooks + .iter() + .map(|webhook| webhook.name.as_str()), + )?; + validate_unique_names( + "context entity", + automaton + .context_entities + .iter() + .map(|context| context.name.as_str()), + )?; + validate_unique_names( + "field invariant", + automaton + .field_invariants + .iter() + .map(|invariant| invariant.name.as_str()), + )?; + validate_unique_names("key", automaton.keys.iter().map(|key| key.name.as_str()))?; + validate_unique_names( + "vector path", + automaton.vectors.iter().map(|vector| vector.name.as_str()), + )?; + + for action in &automaton.actions { + validate_unique_names( + &format!("parameter on action '{}'", action.name), + action.params.iter().map(ActionParam::name), + )?; + } + for invariant in &automaton.invariants { + if invariant.assert.trim().is_empty() { + return Err(AutomatonParseError::Validation(format!( + "invariant '{}' must declare a non-empty assertion", + invariant.name + ))); + } + } + for property in &automaton.liveness { + if property.reaches.is_empty() && property.has_actions != Some(true) { + return Err(AutomatonParseError::Validation(format!( + "liveness property '{}' must declare non-empty reaches or has_actions = true", + property.name + ))); + } + } + // 1. Initial state must be in the states list. if !automaton .automaton @@ -665,14 +804,7 @@ fn validate_vector_decls(automaton: &Automaton) -> Result<(), AutomatonParseErro const METRICS: [&str; 3] = ["cosine", "dot", "l2"]; let state_var_names: std::collections::BTreeSet<&str> = automaton.state.iter().map(|sv| sv.name.as_str()).collect(); - let mut seen_names: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); for vec_decl in &automaton.vectors { - if !seen_names.insert(vec_decl.name.as_str()) { - return Err(AutomatonParseError::Validation(format!( - "vector path '{}' declared twice", - vec_decl.name - ))); - } if !state_var_names.contains(vec_decl.property.as_str()) { return Err(AutomatonParseError::Validation(format!( "vector path '{}' references undeclared property state variable '{}'", diff --git a/crates/temper-spec/src/automaton/parser_core_test.rs b/crates/temper-spec/src/automaton/parser_core_test.rs index b8ae332b5..170163028 100644 --- a/crates/temper-spec/src/automaton/parser_core_test.rs +++ b/crates/temper-spec/src/automaton/parser_core_test.rs @@ -11,6 +11,22 @@ fn test_parse_order_automaton() { assert!(automaton.automaton.states.contains(&"Shipped".to_string())); } +#[test] +fn test_automaton_version_round_trips() { + let spec = r#" +[automaton] +name = "Versioned" +version = "1.0.0" +states = ["Active"] +initial = "Active" +"#; + + let parsed = parse_automaton(spec).expect("versioned automaton should parse"); + let canonical = toml::to_string(&parsed).expect("versioned automaton should serialize"); + assert!(canonical.contains("version = \"1.0.0\"")); + parse_automaton(&canonical).expect("canonical versioned automaton should reparse"); +} + #[test] fn test_actions_parsed() { let automaton = parse_automaton(ORDER_IOA).unwrap(); diff --git a/crates/temper-spec/src/automaton/parser_features_test.rs b/crates/temper-spec/src/automaton/parser_features_test.rs index b048cb246..6d8bd2fb8 100644 --- a/crates/temper-spec/src/automaton/parser_features_test.rs +++ b/crates/temper-spec/src/automaton/parser_features_test.rs @@ -209,7 +209,7 @@ initial = "Active" name = "AddTask" from = ["Active"] effect = [ - { type = "spawn_entity", entity_type = "Task", entity_id_source = "{uuid}", initial_action = "Create" }, + { type = "spawn_entity", entity_type = "Task", entity_id_source = "{uuid}", initial_action = "Create", copy_fields = "system_prompt, model, tools_enabled" }, { type = "emit_event", event = "TaskAdded" } ] "#; @@ -219,10 +219,18 @@ effect = [ .iter() .find(|action| action.name == "AddTask") .expect("AddTask action should exist"); - assert!(matches!( - add_task.effect.first(), - Some(Effect::Spawn { .. }) - )); + let Some(Effect::Spawn { copy_fields, .. }) = add_task.effect.first() else { + panic!( + "expected a spawn effect, got: {:?}", + add_task.effect.first() + ); + }; + assert_eq!( + copy_fields + .as_ref() + .expect("legacy copy_fields should be preserved"), + &["system_prompt", "model", "tools_enabled"].map(ToString::to_string) + ); assert!(matches!(add_task.effect.get(1), Some(Effect::Emit { .. }))); } diff --git a/crates/temper-spec/src/automaton/parser_integrations_test.rs b/crates/temper-spec/src/automaton/parser_integrations_test.rs index b417291f5..ebd17cd7c 100644 --- a/crates/temper-spec/src/automaton/parser_integrations_test.rs +++ b/crates/temper-spec/src/automaton/parser_integrations_test.rs @@ -229,6 +229,7 @@ type = "wasm" module = "http_fetch" on_success = "FetchSucceeded" on_failure = "FetchFailed" +config = "legacy-config" url = "https://api.open-meteo.com/v1/forecast" method = "GET" "#; @@ -246,10 +247,25 @@ method = "GET" integration.config.get("method").map(String::as_str), Some("GET") ); + assert_eq!( + integration.config.get("config").map(String::as_str), + Some("legacy-config") + ); assert!(!integration.config.contains_key("name")); assert!(!integration.config.contains_key("trigger")); assert!(!integration.config.contains_key("type")); assert!(!integration.config.contains_key("module")); + + let canonical = toml::to_string(&automaton).expect("integration should serialize"); + let reparsed = parse_automaton(&canonical).expect("canonical integration should reparse"); + assert_eq!( + reparsed.integrations[0] + .config + .get("config") + .map(String::as_str), + Some("legacy-config"), + "legacy scalar config key must survive canonical nested serialization" + ); } #[test] diff --git a/crates/temper-spec/src/automaton/parser_strictness_test.rs b/crates/temper-spec/src/automaton/parser_strictness_test.rs index a282e5173..772de439f 100644 --- a/crates/temper-spec/src/automaton/parser_strictness_test.rs +++ b/crates/temper-spec/src/automaton/parser_strictness_test.rs @@ -119,6 +119,22 @@ method = "POST" assert_source_located_rejection(&source, "action"); } +#[test] +fn rejects_trailing_content_in_legacy_structured_effect() { + let source = format!( + r#"{BASE_SPEC} +[[action]] +name = "Complete" +from = ["Ready"] +to = "Ready" +effect = '''[{{ type = "emit", event = "completed" }}] +unknown = "discarded"''' +"# + ); + + assert_source_located_rejection(&source, "unknown"); +} + #[test] fn rejects_duplicate_safety_declaration_names() { let source = format!( @@ -139,6 +155,20 @@ assert = "status \\in {{Ready}}" assert!(message.contains("status_is_valid"), "got: {message}"); } +#[test] +fn rejects_duplicate_safety_keys() { + let source = format!( + r#"{BASE_SPEC} +[[invariant]] +name = "status_is_valid" +assert = "status \\in {{Ready}}" +assert = "status \\in {{Ready}}" +"# + ); + + assert_source_located_rejection(&source, "duplicate key"); +} + #[test] fn retains_context_entities_across_canonical_round_trip() { let source = format!( diff --git a/crates/temper-spec/src/automaton/parser_triggers_test.rs b/crates/temper-spec/src/automaton/parser_triggers_test.rs index 6d81f66ca..4aeeaeefa 100644 --- a/crates/temper-spec/src/automaton/parser_triggers_test.rs +++ b/crates/temper-spec/src/automaton/parser_triggers_test.rs @@ -999,6 +999,166 @@ type = "same_id" assert!(!has_synthesized_effect); } +#[test] +fn canonical_external_trigger_round_trip_is_idempotent() { + let spec = r#" +[automaton] +name = "Job" +states = ["Ready"] +initial = "Ready" + +[[action]] +name = "Run" +from = ["Ready"] + +[[action.triggers]] +name = "worker" +kind = "wasm" +module = "worker" + +[action.triggers.config] +name = "config-name" +trigger = "config-trigger" +type = "config-type" +module = "config-module" +on_success = "config-on-success" +on_failure = "config-on-failure" +llm = "config-llm" +"#; + + let parsed = parse_automaton(spec).expect("external trigger should parse"); + let canonical = toml::to_string(&parsed).expect("parsed automaton should serialize"); + let reparsed = parse_automaton(&canonical).expect("canonical automaton should reparse"); + assert_eq!( + reparsed + .integrations + .iter() + .filter(|integration| integration.name == "__trigger__:Run:worker") + .count(), + 1, + "canonical reparse must not duplicate a synthesized integration" + ); + let integration = reparsed + .integrations + .iter() + .find(|integration| integration.name == "__trigger__:Run:worker") + .expect("synthesized integration must survive the round trip"); + for (key, expected) in [ + ("name", "config-name"), + ("trigger", "config-trigger"), + ("type", "config-type"), + ("module", "config-module"), + ("on_success", "config-on-success"), + ("on_failure", "config-on-failure"), + ("llm", "config-llm"), + ] { + assert_eq!( + integration.config.get(key).map(String::as_str), + Some(expected), + "reserved-looking config key `{key}` must remain config" + ); + } + assert_eq!( + toml::to_string(&reparsed).expect("reparsed automaton should serialize"), + canonical, + "canonical serialization must be stable" + ); +} + +#[test] +fn authored_integration_cannot_conflict_with_synthesized_trigger_record() { + let spec = r#" +[automaton] +name = "Job" +states = ["Ready"] +initial = "Ready" + +[[action]] +name = "Run" +from = ["Ready"] + +[[action.triggers]] +name = "worker" +kind = "wasm" +module = "worker" + +[[integration]] +name = "__trigger__:Run:worker" +trigger = "__trigger__:Run:worker" +type = "wasm" +module = "different-worker" +"#; + + let error = parse_automaton(spec) + .expect_err("an authored integration must not shadow a synthesized trigger record"); + assert!(error.to_string().contains("conflicts"), "got: {error}"); +} + +#[test] +fn authored_integration_cannot_shadow_synthesized_trigger_dispatch_key() { + let spec = r#" +[automaton] +name = "Job" +states = ["Ready"] +initial = "Ready" + +[[action]] +name = "Run" +from = ["Ready"] + +[[action.triggers]] +name = "worker" +kind = "wasm" +module = "worker" + +[[integration]] +name = "different-name" +trigger = "__trigger__:Run:worker" +type = "wasm" +module = "shadow-worker" +"#; + + let error = parse_automaton(spec) + .expect_err("an authored integration must not shadow a synthesized dispatch key"); + assert!(error.to_string().contains("conflicts"), "got: {error}"); +} + +#[test] +fn canonical_integration_cannot_hide_later_trigger_dispatch_conflict() { + let spec = r#" +[automaton] +name = "Job" +states = ["Ready"] +initial = "Ready" + +[[action]] +name = "Run" +from = ["Ready"] + +[[action.triggers]] +name = "worker" +kind = "wasm" +module = "worker" + +[[integration]] +name = "__trigger__:Run:worker" +trigger = "__trigger__:Run:worker" +type = "wasm" +module = "worker" + +[[integration]] +name = "different-name" +trigger = "__trigger__:Run:worker" +type = "wasm" +module = "shadow-worker" +"#; + + let error = parse_automaton(spec).expect_err( + "an exact canonical integration must not hide a later conflicting dispatch key", + ); + assert!(error.to_string().contains("conflicts"), "got: {error}"); +} + // test_agent_trigger_section_does_not_overwrite_previous_action removed — // ADR-0046 deleted the [[agent_trigger]] section. The equivalent // invariant ([[action.triggers]] body doesn't leak into action fields) is diff --git a/crates/temper-spec/src/automaton/toml_parser/compatibility.rs b/crates/temper-spec/src/automaton/toml_parser/compatibility.rs new file mode 100644 index 000000000..ac652b536 --- /dev/null +++ b/crates/temper-spec/src/automaton/toml_parser/compatibility.rs @@ -0,0 +1,358 @@ +//! Narrow deserialization compatibility for historical IOA syntax. + +use serde::Deserialize; +use serde::de::{self, Deserializer}; + +use super::super::types::{CompositeCedarGate, Effect, Guard}; + +pub(in crate::automaton) fn deserialize_guards<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + match toml::Value::deserialize(deserializer)? { + toml::Value::String(source) => parse_legacy_guard(&source) + .map(|guard| vec![guard]) + .map_err(de::Error::custom), + toml::Value::Array(guards) => guards + .into_iter() + .map(|entry| match entry { + toml::Value::String(source) => parse_legacy_guard(&source), + value @ toml::Value::Table(_) => { + Guard::deserialize(value).map_err(|error| error.to_string()) + } + value => Err(format!( + "guard entries must be strings or tables, got {value}" + )), + }) + .collect::, _>>() + .map_err(de::Error::custom), + value => Err(de::Error::custom(format!( + "action.guard must be a string or array, got {value}" + ))), + } +} + +pub(in crate::automaton) fn deserialize_effects<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = toml::Value::deserialize(deserializer)?; + deserialize_effect_value(value).map_err(de::Error::custom) +} + +fn deserialize_effect_value(value: toml::Value) -> Result, String> { + match value { + toml::Value::String(source) if source.trim_start().starts_with('[') => { + // Some historical specs wrapped a structured effect array in a + // TOML string. Decode only that field-local legacy payload; the + // source document itself is still parsed once as one Automaton. + let document = format!("effect = {source}"); + let mut wrapper = document + .parse::() + .map_err(|error| format!("invalid legacy structured effect: {error}"))?; + let value = wrapper + .remove("effect") + .ok_or_else(|| "legacy structured effect must declare an array".to_string())?; + if let Some(unexpected) = wrapper.keys().next() { + return Err(format!( + "legacy structured effect contains unexpected field `{unexpected}`" + )); + } + deserialize_effect_value(value) + } + toml::Value::String(source) => parse_legacy_effect(&source).map(|effect| vec![effect]), + toml::Value::Array(effects) => effects.into_iter().map(deserialize_effect_entry).collect(), + value => Err(format!( + "action.effect must be a string or array, got {value}" + )), + } +} + +fn deserialize_effect_entry(value: toml::Value) -> Result { + match value { + toml::Value::String(source) => parse_legacy_effect(&source), + value @ toml::Value::Table(_) => { + let effect_type = value + .as_table() + .and_then(|fields| fields.get("type")) + .and_then(toml::Value::as_str) + .ok_or_else(|| "structured effect must declare string field `type`".to_string())?; + if !matches!( + effect_type, + "increment" + | "decrement" + | "set_counter_from_param" + | "set_bool" + | "emit" + | "emit_event" + | "list_append" + | "list_remove_at" + | "trigger" + | "schedule" + | "schedule_at" + | "spawn" + | "spawn_entity" + ) { + return Err(format!("unsupported effect type '{effect_type}'")); + } + Effect::deserialize(value).map_err(|error| error.to_string()) + } + value => Err(format!( + "effect entries must be strings or tables, got {value}" + )), + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum BoolSource { + Bool(bool), + String(String), +} + +pub(in crate::automaton) fn deserialize_boolish<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + match BoolSource::deserialize(deserializer)? { + BoolSource::Bool(value) => Ok(value), + BoolSource::String(value) if value.eq_ignore_ascii_case("true") => Ok(true), + BoolSource::String(value) if value.eq_ignore_ascii_case("false") => Ok(false), + BoolSource::String(value) => Err(de::Error::custom(format!( + "expected boolean or string \"true\"/\"false\", got {value:?}" + ))), + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum CopyFieldsSource { + List(Vec), + CommaSeparated(String), +} + +pub(in crate::automaton) fn deserialize_copy_fields<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + match CopyFieldsSource::deserialize(deserializer)? { + CopyFieldsSource::List(fields) => Ok(Some(fields)), + CopyFieldsSource::CommaSeparated(source) => { + let fields = source + .split(',') + .map(str::trim) + .filter(|field| !field.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + Ok((!fields.is_empty()).then_some(fields)) + } + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum CedarGateSource { + Single(CompositeCedarGate), + Array(Vec), +} + +pub(in crate::automaton) fn deserialize_cedar_gate<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + match CedarGateSource::deserialize(deserializer)? { + CedarGateSource::Single(gate) => Ok(Some(gate)), + CedarGateSource::Array(mut gates) if gates.len() == 1 => Ok(gates.pop()), + CedarGateSource::Array(gates) => Err(de::Error::custom(format!( + "action.cedar_gate must be declared exactly once, found {} declarations", + gates.len() + ))), + } +} + +fn parse_legacy_guard(source: &str) -> Result { + let source = source.trim(); + for &(operator, is_minimum) in &[(">=", true), ("<=", false), (">", true), ("<", false)] { + if let Some(position) = source.find(operator) { + return parse_infix_guard(source, operator, position, is_minimum); + } + } + + if let Some(rest) = source.strip_prefix('!') { + let var = rest.trim(); + if var.is_empty() || var.contains(char::is_whitespace) { + return Err(format!("invalid guard {source:?} (expected '!')")); + } + return Ok(Guard::IsFalse { + var: var.to_string(), + }); + } + + parse_prefix_guard(source) +} + +fn parse_infix_guard( + source: &str, + operator: &str, + position: usize, + is_minimum: bool, +) -> Result { + let var = source[..position].trim(); + let raw_number = source[position + operator.len()..].trim(); + if var.is_empty() || raw_number.is_empty() { + return Err(format!( + "invalid guard {source:?} (expected ' {operator} ')" + )); + } + let number = raw_number + .parse::() + .map_err(|_| format!("invalid guard {source:?} (right side must be an integer)"))?; + + if is_minimum { + let min = if operator == ">=" { + number + } else { + number + .checked_add(1) + .ok_or_else(|| format!("invalid guard {source:?} (integer overflow)"))? + }; + Ok(Guard::MinCount { + var: var.to_string(), + min, + }) + } else { + let max = if operator == "<" { + number + } else { + number + .checked_add(1) + .ok_or_else(|| format!("invalid guard {source:?} (integer overflow)"))? + }; + Ok(Guard::MaxCount { + var: var.to_string(), + max, + }) + } +} + +fn parse_prefix_guard(source: &str) -> Result { + let parts = source.split_whitespace().collect::>(); + match parts.as_slice() { + ["min", var, value] => Ok(Guard::MinCount { + var: (*var).to_string(), + min: parse_guard_number(source, value)?, + }), + ["max", var, value] => Ok(Guard::MaxCount { + var: (*var).to_string(), + max: parse_guard_number(source, value)?, + }), + ["is_true", var] => Ok(Guard::IsTrue { + var: (*var).to_string(), + }), + ["is_false", var] => Ok(Guard::IsFalse { + var: (*var).to_string(), + }), + ["list_length_min", var, value] => Ok(Guard::ListLengthMin { + var: (*var).to_string(), + min: parse_guard_number(source, value)?, + }), + ["list_contains", var, values @ ..] if !values.is_empty() => Ok(Guard::ListContains { + var: (*var).to_string(), + value: values.join(" "), + }), + [var] + if var + .chars() + .all(|character| character.is_alphanumeric() || character == '_') => + { + Ok(Guard::IsTrue { + var: (*var).to_string(), + }) + } + _ => Err(format!("unsupported guard syntax {source:?}")), + } +} + +fn parse_guard_number(source: &str, value: &str) -> Result { + value + .parse() + .map_err(|_| format!("invalid guard {source:?} (expected an unsigned integer)")) +} + +fn parse_legacy_effect(source: &str) -> Result { + let source = source.trim(); + if let Some((var, amount)) = parse_counter_effect(source, "increment ") { + return Ok(Effect::Increment { var, amount }); + } + if let Some((var, amount)) = parse_counter_effect(source, "decrement ") { + return Ok(Effect::Decrement { var, amount }); + } + if let Some(rest) = source.strip_prefix("set ") { + let parts = rest.split_whitespace().collect::>(); + return match parts.as_slice() { + [var, "true"] => Ok(Effect::SetBool { + var: (*var).to_string(), + value: true, + }), + [var, "false"] => Ok(Effect::SetBool { + var: (*var).to_string(), + value: false, + }), + _ => Err(format!( + "invalid effect {source:?} (expected 'set true|false')" + )), + }; + } + if let Some(event) = parse_prefixed_identifier(source, "emit ") { + return Ok(Effect::Emit { event }); + } + if let Some(rest) = source.strip_prefix("schedule_at ") { + let parts = rest.split_whitespace().collect::>(); + return match parts.as_slice() { + [field, action] => Ok(Effect::ScheduleAt { + action: (*action).to_string(), + field: (*field).to_string(), + }), + _ => Err(format!( + "invalid effect {source:?} (expected 'schedule_at ')" + )), + }; + } + if let Some(name) = parse_prefixed_identifier(source, "trigger ") { + return Ok(Effect::Trigger { name }); + } + + Err(format!("unsupported effect syntax {source:?}")) +} + +fn parse_counter_effect(source: &str, prefix: &str) -> Option<(String, Option)> { + let rest = source.strip_prefix(prefix)?.trim(); + if rest.is_empty() { + return None; + } + if let Some((var, amount)) = rest.split_once(" by ") { + let var = var.trim(); + let amount = amount.trim(); + if var.is_empty() || amount.is_empty() { + return None; + } + return Some((var.to_string(), Some(amount.to_string()))); + } + Some((rest.to_string(), None)) +} + +fn parse_prefixed_identifier(source: &str, prefix: &str) -> Option { + source + .strip_prefix(prefix) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} diff --git a/crates/temper-spec/src/automaton/toml_parser/effects.rs b/crates/temper-spec/src/automaton/toml_parser/effects.rs deleted file mode 100644 index 442d305dc..000000000 --- a/crates/temper-spec/src/automaton/toml_parser/effects.rs +++ /dev/null @@ -1,200 +0,0 @@ -use super::AutomatonParseError; -use super::inline::{parse_inline_fields, split_inline_tables}; -use crate::automaton::Effect; - -pub(super) fn parse_effect_value( - value: &str, - effects: &mut Vec, -) -> Result<(), AutomatonParseError> { - let trimmed = value.trim(); - - if trimmed.starts_with('[') && trimmed.contains('{') { - return parse_effect_array(trimmed, effects); - } - - if let Some(effect) = parse_legacy_effect(trimmed) { - effects.push(effect); - } - - Ok(()) -} - -fn parse_effect_array(value: &str, effects: &mut Vec) -> Result<(), AutomatonParseError> { - let trimmed = value.trim(); - if !trimmed.starts_with('[') || !trimmed.ends_with(']') { - return Ok(()); - } - - let inner = &trimmed[1..trimmed.len() - 1]; - for entry in split_inline_tables(inner) { - let entry = entry.trim().trim_matches('{').trim_matches('}').trim(); - let fields = parse_inline_fields(entry); - - if let Some(effect) = parse_effect_fields(&fields)? { - effects.push(effect); - } - } - - Ok(()) -} - -fn parse_effect_fields( - fields: &std::collections::BTreeMap, -) -> Result, AutomatonParseError> { - let effect_type = fields.get("type").map(|s| s.as_str()).unwrap_or(""); - - let effect = match effect_type { - "schedule" => { - let action = fields.get("action").cloned().unwrap_or_default(); - if action.is_empty() { - None - } else { - let delay_seconds = fields - .get("delay_seconds") - .and_then(|s| s.parse().ok()) - .unwrap_or(0); - Some(Effect::Schedule { - action, - delay_seconds, - }) - } - } - "schedule_at" => { - let action = fields.get("action").cloned().unwrap_or_default(); - let field = fields.get("field").cloned().unwrap_or_default(); - if action.is_empty() || field.is_empty() { - None - } else { - Some(Effect::ScheduleAt { action, field }) - } - } - "increment" => fields.get("var").cloned().map(|var| Effect::Increment { - var, - amount: fields.get("amount").cloned(), - }), - "decrement" => fields.get("var").cloned().map(|var| Effect::Decrement { - var, - amount: fields.get("amount").cloned(), - }), - "set_counter_from_param" => fields.get("var").cloned().map(|var| { - let param = fields.get("param").cloned().unwrap_or_else(|| var.clone()); - Effect::SetCounterFromParam { var, param } - }), - "set_bool" => fields.get("var").cloned().map(|var| Effect::SetBool { - var, - value: fields.get("value").is_some_and(|s| s == "true"), - }), - "emit" | "emit_event" => fields - .get("event") - .cloned() - .map(|event| Effect::Emit { event }), - "trigger" => fields - .get("name") - .cloned() - .map(|name| Effect::Trigger { name }), - "list_append" => list_var(fields).map(|var| Effect::ListAppend { var }), - "list_remove_at" => list_var(fields).map(|var| Effect::ListRemoveAt { var }), - "spawn" | "spawn_entity" => { - let entity_type = fields.get("entity_type").cloned().unwrap_or_default(); - if entity_type.is_empty() { - None - } else { - let copy_fields = fields.get("copy_fields").and_then(|s| { - let names: Vec = s - .split(',') - .map(|f| f.trim().to_string()) - .filter(|f| !f.is_empty()) - .collect(); - if names.is_empty() { None } else { Some(names) } - }); - Some(Effect::Spawn { - entity_type, - entity_id_source: fields.get("entity_id_source").cloned().unwrap_or_default(), - initial_action: fields.get("initial_action").cloned(), - store_id_in: fields.get("store_id_in").cloned(), - copy_fields, - }) - } - } - _ => { - return Err(AutomatonParseError::Validation(format!( - "unsupported effect type '{effect_type}'" - ))); - } - }; - - Ok(effect) -} - -fn parse_legacy_effect(value: &str) -> Option { - if let Some((var, amount)) = parse_counter_effect(value, "increment ") { - return Some(Effect::Increment { var, amount }); - } - - if let Some((var, amount)) = parse_counter_effect(value, "decrement ") { - return Some(Effect::Decrement { var, amount }); - } - - if let Some((var, bool_value)) = parse_bool_set(value) { - return Some(Effect::SetBool { - var, - value: bool_value, - }); - } - - if let Some(event) = parse_prefixed_identifier(value, "emit ") { - return Some(Effect::Emit { event }); - } - - if let Some(rest) = value.strip_prefix("schedule_at ") { - let parts: Vec<&str> = rest.splitn(2, ' ').collect(); - if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() { - return Some(Effect::ScheduleAt { - field: parts[0].to_string(), - action: parts[1].to_string(), - }); - } - } - - parse_prefixed_identifier(value, "trigger ").map(|name| Effect::Trigger { name }) -} - -fn parse_prefixed_identifier(value: &str, prefix: &str) -> Option { - value - .strip_prefix(prefix) - .map(str::trim) - .filter(|candidate| !candidate.is_empty()) - .map(ToOwned::to_owned) -} - -fn parse_counter_effect(value: &str, prefix: &str) -> Option<(String, Option)> { - let rest = value.strip_prefix(prefix)?.trim(); - if rest.is_empty() { - return None; - } - if let Some((var, amount)) = rest.split_once(" by ") { - let var = var.trim(); - let amount = amount.trim(); - if var.is_empty() || amount.is_empty() { - return None; - } - return Some((var.to_string(), Some(amount.to_string()))); - } - Some((rest.to_string(), None)) -} - -fn parse_bool_set(value: &str) -> Option<(String, bool)> { - let parts: Vec<&str> = value.splitn(3, ' ').collect(); - if parts.len() != 3 || parts[0] != "set" { - return None; - } - - Some((parts[1].to_string(), parts[2].trim() == "true")) -} - -fn list_var(fields: &std::collections::BTreeMap) -> Option { - fields - .get("var") - .cloned() - .or_else(|| fields.get("list").cloned()) -} diff --git a/crates/temper-spec/src/automaton/toml_parser/guards.rs b/crates/temper-spec/src/automaton/toml_parser/guards.rs deleted file mode 100644 index 21bf35a2c..000000000 --- a/crates/temper-spec/src/automaton/toml_parser/guards.rs +++ /dev/null @@ -1,254 +0,0 @@ -use super::AutomatonParseError; -use super::inline::{parse_inline_fields, parse_string_array, split_inline_tables}; -use crate::automaton::Guard; - -pub(super) fn parse_guard_value( - value: &str, - guards: &mut Vec, -) -> Result<(), AutomatonParseError> { - let trimmed = value.trim(); - - if trimmed.starts_with('[') && trimmed.contains('{') { - return parse_guard_array(trimmed, guards); - } - - guards.push(parse_guard_clause(trimmed)?); - Ok(()) -} - -pub(super) fn parse_guard_clause(value: &str) -> Result { - let trimmed = value.trim(); - - for &(operator, is_min_guard) in &[(">=", true), ("<=", false), (">", true), ("<", false)] { - if let Some(pos) = trimmed.find(operator) { - return parse_infix_guard(trimmed, operator, pos, is_min_guard); - } - } - - if let Some(rest) = trimmed.strip_prefix('!') { - return parse_negated_guard(trimmed, rest); - } - - parse_prefix_guard(trimmed) -} - -fn parse_guard_array(value: &str, guards: &mut Vec) -> Result<(), AutomatonParseError> { - let trimmed = value.trim(); - if !trimmed.starts_with('[') || !trimmed.ends_with(']') { - return Ok(()); - } - - let inner = &trimmed[1..trimmed.len() - 1]; - for entry in split_inline_tables(inner) { - let entry = entry.trim().trim_matches('{').trim_matches('}').trim(); - guards.push(parse_guard_fields(&parse_inline_fields(entry))?); - } - - Ok(()) -} - -fn parse_guard_fields( - fields: &std::collections::BTreeMap, -) -> Result { - let guard_type = fields.get("type").map(|s| s.as_str()).unwrap_or(""); - - let guard = match guard_type { - "cross_entity_state" => Guard::CrossEntityState { - entity_type: fields.get("entity_type").cloned().unwrap_or_default(), - entity_id_source: fields.get("entity_id_source").cloned().unwrap_or_default(), - required_status: fields - .get("required_status") - .map(|s| parse_string_array(s)) - .unwrap_or_default(), - forbidden_status: fields - .get("forbidden_status") - .map(|s| parse_string_array(s)) - .unwrap_or_default(), - required: fields - .get("required") - .map(|s| s.eq_ignore_ascii_case("true")) - .unwrap_or(false), - }, - "state_in" => Guard::StateIn { - values: fields - .get("values") - .map(|s| parse_string_array(s)) - .unwrap_or_default(), - }, - "min_count" => Guard::MinCount { - var: fields.get("var").cloned().unwrap_or_default(), - min: fields.get("min").and_then(|s| s.parse().ok()).unwrap_or(0), - }, - "max_count" => Guard::MaxCount { - var: fields.get("var").cloned().unwrap_or_default(), - max: fields.get("max").and_then(|s| s.parse().ok()).unwrap_or(0), - }, - "is_true" => Guard::IsTrue { - var: fields.get("var").cloned().unwrap_or_default(), - }, - "is_false" => Guard::IsFalse { - var: fields.get("var").cloned().unwrap_or_default(), - }, - "list_contains" => Guard::ListContains { - var: fields.get("var").cloned().unwrap_or_default(), - value: fields.get("value").cloned().unwrap_or_default(), - }, - "list_length_min" => Guard::ListLengthMin { - var: fields.get("var").cloned().unwrap_or_default(), - min: fields.get("min").and_then(|s| s.parse().ok()).unwrap_or(0), - }, - _ => { - return Err(AutomatonParseError::Validation(format!( - "unsupported guard type '{guard_type}'" - ))); - } - }; - - Ok(guard) -} - -fn parse_infix_guard( - trimmed: &str, - operator: &str, - position: usize, - is_min_guard: bool, -) -> Result { - let var = trimmed[..position].trim(); - let raw = trimmed[position + operator.len()..].trim(); - if var.is_empty() || raw.is_empty() { - return Err(AutomatonParseError::Validation(format!( - "invalid guard '{trimmed}' (expected ' {operator} ')" - ))); - } - - let number = raw.parse::().map_err(|_| { - AutomatonParseError::Validation(format!( - "invalid guard '{trimmed}' (right side must be an integer)" - )) - })?; - - if is_min_guard { - let min = if operator == ">=" { number } else { number + 1 }; - return Ok(Guard::MinCount { - var: var.to_string(), - min, - }); - } - - let max = if operator == "<=" { number + 1 } else { number }; - Ok(Guard::MaxCount { - var: var.to_string(), - max, - }) -} - -fn parse_negated_guard(trimmed: &str, rest: &str) -> Result { - let var = rest.trim(); - if var.is_empty() || var.contains(' ') { - return Err(AutomatonParseError::Validation(format!( - "invalid guard '{trimmed}' (expected '!')" - ))); - } - - Ok(Guard::IsFalse { - var: var.to_string(), - }) -} - -fn parse_prefix_guard(trimmed: &str) -> Result { - let parts: Vec<&str> = trimmed.split_whitespace().collect(); - if parts.is_empty() { - return Err(AutomatonParseError::Validation( - "empty guard clause".to_string(), - )); - } - - match parts[0] { - "min" => Ok(Guard::MinCount { - var: parts - .get(1) - .ok_or_else(|| invalid_guard(trimmed, "expected 'min '"))? - .to_string(), - min: parse_usize_arg(trimmed, parts.get(2), "min must be an integer")?, - }), - "max" => Ok(Guard::MaxCount { - var: parts - .get(1) - .ok_or_else(|| invalid_guard(trimmed, "expected 'max '"))? - .to_string(), - max: parse_usize_arg(trimmed, parts.get(2), "max must be an integer")?, - }), - "is_true" => parse_boolean_guard(trimmed, &parts, true), - "is_false" => parse_boolean_guard(trimmed, &parts, false), - "list_contains" => { - if parts.len() < 3 { - return Err(invalid_guard( - trimmed, - "expected 'list_contains '", - )); - } - Ok(Guard::ListContains { - var: parts[1].to_string(), - value: parts[2..].join(" "), - }) - } - "list_length_min" => Ok(Guard::ListLengthMin { - var: parts - .get(1) - .ok_or_else(|| invalid_guard(trimmed, "expected 'list_length_min '"))? - .to_string(), - min: parse_usize_arg(trimmed, parts.get(2), "min must be an integer")?, - }), - _ if parts.len() == 1 && parts[0].chars().all(|c| c.is_alphanumeric() || c == '_') => { - Ok(Guard::IsTrue { - var: parts[0].to_string(), - }) - } - _ => Err(AutomatonParseError::Validation(format!( - "unsupported guard syntax '{trimmed}'" - ))), - } -} - -fn parse_boolean_guard( - trimmed: &str, - parts: &[&str], - expected_true: bool, -) -> Result { - if parts.len() != 2 { - let expected = if expected_true { - "expected 'is_true '" - } else { - "expected 'is_false '" - }; - return Err(invalid_guard(trimmed, expected)); - } - - Ok(if expected_true { - Guard::IsTrue { - var: parts[1].to_string(), - } - } else { - Guard::IsFalse { - var: parts[1].to_string(), - } - }) -} - -fn parse_usize_arg( - trimmed: &str, - value: Option<&&str>, - message: &str, -) -> Result { - let Some(value) = value else { - return Err(invalid_guard(trimmed, "expected ' '")); - }; - - value.parse().map_err(|_| { - AutomatonParseError::Validation(format!("invalid guard '{trimmed}' ({message})")) - }) -} - -fn invalid_guard(trimmed: &str, message: &str) -> AutomatonParseError { - AutomatonParseError::Validation(format!("invalid guard '{trimmed}' ({message})")) -} diff --git a/crates/temper-spec/src/automaton/toml_parser/inline.rs b/crates/temper-spec/src/automaton/toml_parser/inline.rs deleted file mode 100644 index 6d285f007..000000000 --- a/crates/temper-spec/src/automaton/toml_parser/inline.rs +++ /dev/null @@ -1,204 +0,0 @@ -pub(super) fn parse_kv(line: &str) -> Option<(&str, String)> { - let eq = line.find('=')?; - let key = line[..eq].trim(); - let raw_value = line[eq + 1..].trim(); - let value = raw_value.trim_matches('"').trim_matches('\'').to_string(); - Some((key, value)) -} - -pub(super) fn parse_string_array(value: &str) -> Vec { - let trimmed = value.trim(); - if trimmed.starts_with('[') && trimmed.ends_with(']') { - let inner = &trimmed[1..trimmed.len() - 1]; - return split_top_level(inner, ',') - .into_iter() - .map(|item| item.trim().trim_matches('"').trim_matches('\'').to_string()) - .filter(|item| !item.is_empty()) - .collect(); - } - - vec![trimmed.trim_matches('"').trim_matches('\'').to_string()] -} - -pub(super) fn parse_action_params(value: &str) -> Vec { - let trimmed = value.trim(); - if trimmed.contains('{') { - let toml_str = format!("params = {trimmed}"); - #[derive(serde::Deserialize)] - struct Wrapper { - params: Vec, - } - if let Ok(w) = toml::from_str::(&toml_str) { - return w.params; - } - } - parse_string_array(trimmed) - .into_iter() - .map(super::super::types::ActionParam::Named) - .collect() -} - -pub(super) fn split_inline_tables(s: &str) -> Vec<&str> { - let mut result = Vec::new(); - let mut depth: usize = 0; - let mut start = None; - let mut in_single_quote = false; - let mut in_double_quote = false; - let mut escaped = false; - - for (index, ch) in s.char_indices() { - if in_double_quote && ch == '\\' { - escaped = !escaped; - continue; - } - - if ch == '"' && !in_single_quote && !escaped { - in_double_quote = !in_double_quote; - } else if ch == '\'' && !in_double_quote { - in_single_quote = !in_single_quote; - } - - if ch != '\\' { - escaped = false; - } - - if in_single_quote || in_double_quote { - continue; - } - - match ch { - '{' => { - if depth == 0 { - start = Some(index); - } - depth += 1; - } - '}' => { - depth = depth.saturating_sub(1); - if depth == 0 - && let Some(start_index) = start.take() - { - result.push(&s[start_index..=index]); - } - } - _ => {} - } - } - - result -} - -pub(super) fn parse_inline_fields(s: &str) -> std::collections::BTreeMap { - let mut map = std::collections::BTreeMap::new(); - for pair in split_top_level(s, ',') { - let pair = pair.trim(); - if let Some(eq_pos) = pair.find('=') { - let key = pair[..eq_pos].trim().to_string(); - let val = pair[eq_pos + 1..] - .trim() - .trim_matches('"') - .trim_matches('\'') - .to_string(); - map.insert(key, val); - } - } - map -} - -/// Join multiline array values into single logical lines. -/// -/// When a TOML line has unbalanced brackets (e.g., `effect = [`), this -/// function accumulates subsequent lines until brackets are balanced, -/// producing a single logical line for the parser. -pub(super) fn join_multiline_arrays(input: &str) -> Vec { - let mut result = Vec::new(); - let mut buffer = String::new(); - let mut bracket_depth: i32 = 0; - - for line in input.lines() { - let trimmed = line.trim(); - - if bracket_depth > 0 { - buffer.push(' '); - buffer.push_str(trimmed); - bracket_depth += net_bracket_depth(trimmed); - if bracket_depth <= 0 { - result.push(std::mem::take(&mut buffer)); - bracket_depth = 0; - } - continue; - } - - let value_part = trimmed - .find('=') - .map(|eq_pos| &trimmed[eq_pos + 1..]) - .unwrap_or(trimmed); - let depth = net_bracket_depth(value_part); - if depth > 0 { - buffer = trimmed.to_string(); - bracket_depth = depth; - } else { - result.push(trimmed.to_string()); - } - } - - if !buffer.is_empty() { - result.push(buffer); - } - - result -} - -fn split_top_level(s: &str, delimiter: char) -> Vec<&str> { - let mut result = Vec::new(); - let mut start = 0; - let mut bracket_depth = 0_i32; - let mut brace_depth = 0_i32; - let mut in_single_quote = false; - let mut in_double_quote = false; - let mut escaped = false; - - for (index, ch) in s.char_indices() { - if in_double_quote && ch == '\\' { - escaped = !escaped; - continue; - } - - if ch == '"' && !in_single_quote && !escaped { - in_double_quote = !in_double_quote; - } else if ch == '\'' && !in_double_quote { - in_single_quote = !in_single_quote; - } - - if ch != '\\' { - escaped = false; - } - - if in_single_quote || in_double_quote { - continue; - } - - match ch { - '[' => bracket_depth += 1, - ']' => bracket_depth -= 1, - '{' => brace_depth += 1, - '}' => brace_depth -= 1, - _ if ch == delimiter && bracket_depth == 0 && brace_depth == 0 => { - result.push(&s[start..index]); - start = index + ch.len_utf8(); - } - _ => {} - } - } - - result.push(&s[start..]); - result -} - -fn net_bracket_depth(value: &str) -> i32 { - value.chars().fold(0, |depth, ch| match ch { - '[' => depth + 1, - ']' => depth - 1, - _ => depth, - }) -} diff --git a/crates/temper-spec/src/automaton/toml_parser/mod.rs b/crates/temper-spec/src/automaton/toml_parser/mod.rs index d1511ed96..78f4fab1d 100644 --- a/crates/temper-spec/src/automaton/toml_parser/mod.rs +++ b/crates/temper-spec/src/automaton/toml_parser/mod.rs @@ -1,792 +1,20 @@ -//! Minimal TOML parser for I/O Automaton specifications. +//! Canonical TOML parser for I/O Automaton specifications. //! -//! Handles the subset of TOML used by IOA specs since we use a hand-rolled -//! parser rather than the full `toml` crate for the core parsing. Webhook -//! sections are delegated to `toml::from_str` in a second pass. +//! The complete document is deserialized exactly once into [`Automaton`]. +//! Compatibility helpers accept the intentionally supported legacy string +//! syntax for guards and effects without weakening the structured schema. -mod effects; -mod guards; -mod inline; +mod compatibility; use super::parser::AutomatonParseError; -use super::types::*; -use effects::parse_effect_value; -#[cfg(test)] -use guards::parse_guard_clause; -use guards::parse_guard_value; -use inline::{join_multiline_arrays, parse_action_params, parse_kv, parse_string_array}; +use super::types::Automaton; -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -enum Section { - #[default] - None, - Automaton, - State, - Action, - Invariant, - Liveness, - Integration, - FieldInvariant, - StateTimeout, - /// ADR-0153: `[[key]]` unique-key declarations. Passthrough; extracted via - /// serde in the second pass. - Key, - /// ADR-0155: `[[vector]]` vector access-path declarations. Passthrough; - /// extracted via serde in the second pass. - Vector, - Webhook, - /// ADR-0046: nested `[[action.triggers]]` blocks. Hand-rolled parser - /// skips the body; triggers are extracted via serde in the second pass - /// and merged into their action by name. - ActionTrigger, - /// ADR-0040: nested composite-action metadata blocks. Hand-rolled parser - /// skips the body; metadata is extracted via serde in the second pass. - CompositeActionMetadata, -} - -#[derive(Debug, Default)] -struct ParseState { - meta_name: String, - meta_states: Vec, - meta_initial: String, - meta_allow_indefinite_states: Vec, - state_vars: Vec, - actions: Vec, - invariants: Vec, - liveness_props: Vec, - integrations: Vec, - current_section: Section, - current_action: Option, - current_invariant: Option, - current_state_var: Option, - current_liveness: Option, - current_integration: Option, -} - -impl ParseState { - fn enter_section(&mut self, line: &str) -> bool { - match line { - "[automaton]" => self.start_section(Section::Automaton), - "[[state]]" => self.start_state_section(), - "[[action]]" => self.start_action_section(), - "[[invariant]]" => self.start_invariant_section(), - "[[liveness]]" => self.start_liveness_section(), - "[[integration]]" => self.start_integration_section(), - "[[field_invariant]]" => self.start_passthrough_section(Section::FieldInvariant), - // ADR-0049: state_timeouts use nested inline tables for params; - // parse via serde in the second pass rather than field-by-field. - "[[state_timeout]]" => self.start_passthrough_section(Section::StateTimeout), - // ADR-0153: [[key]] unique-key declarations — passthrough; serde - // extracts them in the second pass. - "[[key]]" => self.start_passthrough_section(Section::Key), - // ADR-0155: [[vector]] access-path declarations — passthrough; serde - // extracts them in the second pass. - "[[vector]]" => self.start_passthrough_section(Section::Vector), - "[[webhook]]" => self.start_webhook_section(), - _ if line.starts_with("[webhook.") => self.start_webhook_section(), - // ADR-0046: nested [[action.triggers]] — flush the action body so - // trigger keys don't leak into its fields, then enter passthrough - // (serde extracts triggers in the second pass). - "[[action.triggers]]" => { - self.flush_items(); - self.current_section = Section::ActionTrigger; - true - } - "[[action.cedar_gate]]" | "[[action.sub_writes]]" => { - self.flush_items(); - self.current_section = Section::CompositeActionMetadata; - true - } - _ => false, - } - } - - fn apply_kv(&mut self, key: &str, value: String) -> Result<(), AutomatonParseError> { - match self.current_section { - Section::Automaton => self.apply_automaton_field(key, &value), - Section::State => self.apply_state_field(key, &value), - Section::Action => self.apply_action_field(key, &value)?, - Section::Invariant => self.apply_invariant_field(key, &value), - Section::Liveness => self.apply_liveness_field(key, &value), - Section::Integration => self.apply_integration_field(key, &value), - Section::FieldInvariant - | Section::StateTimeout - | Section::Key - | Section::Vector - | Section::Webhook - | Section::ActionTrigger - | Section::CompositeActionMetadata - | Section::None => {} - } - - Ok(()) - } - - fn finish(mut self, input: &str) -> Result { - self.flush_items(); - self.flush_integration(); - - debug_assert!(self.current_action.is_none()); - debug_assert!(self.current_invariant.is_none()); - debug_assert!(self.current_state_var.is_none()); - debug_assert!(self.current_liveness.is_none()); - debug_assert!(self.current_integration.is_none()); - - // ADR-0046: extract [[action.triggers]] via serde and merge into - // actions by name. The hand-rolled parser skips these blocks. - let mut triggers_by_action = extract_action_triggers(input)?; - let mut composite_by_action = extract_action_composite_metadata(input)?; - let mut actions = self.actions; - for action in &mut actions { - if let Some(trigs) = triggers_by_action.remove(&action.name) { - action.triggers.extend(trigs); - } - if let Some(metadata) = composite_by_action.remove(&action.name) { - action.cedar_gate = metadata.cedar_gate; - action.sub_writes.extend(metadata.sub_writes); - } - } - - Ok(Automaton { - automaton: AutomatonMeta { - name: self.meta_name, - states: self.meta_states, - initial: self.meta_initial, - allow_indefinite_states: self.meta_allow_indefinite_states, - }, - state: self.state_vars, - actions, - invariants: self.invariants, - liveness: self.liveness_props, - integrations: self.integrations, - webhooks: extract_webhooks(input), - context_entities: Vec::new(), - field_invariants: Vec::new(), - state_timeouts: Vec::new(), - keys: Vec::new(), - vectors: Vec::new(), - admission: None, - }) - } - - fn apply_automaton_field(&mut self, key: &str, value: &str) { - match key { - "name" => self.meta_name = value.to_string(), - "initial" => self.meta_initial = value.to_string(), - "states" => self.meta_states = parse_string_array(value), - // ADR-0050: allowlist of states permitted to be indefinite. - "allow_indefinite_states" => { - self.meta_allow_indefinite_states = parse_string_array(value); - } - _ => {} - } - } - - fn apply_state_field(&mut self, key: &str, value: &str) { - let Some(state_var) = self.current_state_var.as_mut() else { - return; - }; - - match key { - "name" => state_var.name = value.to_string(), - "type" => state_var.var_type = value.to_string(), - "initial" => state_var.initial = value.to_string(), - // ADR-0045 / ADR-0047: per-field overflow knobs. - "overflow_inline_max_bytes" => { - if let Ok(v) = value.parse::() { - state_var.overflow_inline_max_bytes = Some(v); - } - } - "overflow_ttl_seconds" => { - if let Ok(v) = value.parse::() { - state_var.overflow_ttl_seconds = Some(v); - } - } - "query_indexed" => match value.trim() { - "true" => state_var.query_indexed = Some(true), - "false" => state_var.query_indexed = Some(false), - _ => {} - }, - _ => {} - } - } - - fn apply_action_field(&mut self, key: &str, value: &str) -> Result<(), AutomatonParseError> { - let Some(action) = self.current_action.as_mut() else { - return Ok(()); - }; - - match key { - "name" => action.name = value.to_string(), - "kind" => action.kind = value.to_string(), - "from" => action.from = parse_string_array(value), - "to" => action.to = Some(value.to_string()), - "params" => action.params = parse_action_params(value), - "hint" => action.hint = Some(value.to_string()), - "record_parent_event" => match value.trim() { - "true" => action.record_parent_event = true, - "false" => action.record_parent_event = false, - _ => {} - }, - "guard" => parse_guard_value(value, &mut action.guard)?, - "effect" => parse_effect_value(value, &mut action.effect)?, - _ => {} - } - - Ok(()) - } - - fn apply_invariant_field(&mut self, key: &str, value: &str) { - let Some(invariant) = self.current_invariant.as_mut() else { - return; - }; - - match key { - "name" => invariant.name = value.to_string(), - "when" => invariant.when = parse_string_array(value), - "assert" => invariant.assert = value.to_string(), - _ => {} - } - } - - fn apply_liveness_field(&mut self, key: &str, value: &str) { - let Some(liveness) = self.current_liveness.as_mut() else { - return; - }; +pub(super) use compatibility::{ + deserialize_boolish, deserialize_cedar_gate, deserialize_copy_fields, deserialize_effects, + deserialize_guards, +}; - match key { - "name" => liveness.name = value.to_string(), - "from" => liveness.from = parse_string_array(value), - "reaches" => liveness.reaches = parse_string_array(value), - "has_actions" => liveness.has_actions = Some(value == "true"), - _ => {} - } - } - - fn apply_integration_field(&mut self, key: &str, value: &str) { - let Some(integration) = self.current_integration.as_mut() else { - return; - }; - - match key { - "name" => integration.name = value.to_string(), - "trigger" => integration.trigger = value.to_string(), - "type" => integration.integration_type = value.to_string(), - "module" => integration.module = Some(value.to_string()), - "on_success" => integration.on_success = Some(value.to_string()), - "on_failure" => integration.on_failure = Some(value.to_string()), - "llm" => integration.llm = value == "true", - _ => { - integration - .config - .insert(key.to_string(), value.to_string()); - } - } - } - - fn flush_items(&mut self) { - if let Some(action) = self.current_action.take() - && !action.name.is_empty() - { - self.actions.push(action); - } - - if let Some(invariant) = self.current_invariant.take() - && !invariant.name.is_empty() - { - self.invariants.push(invariant); - } - - if let Some(state_var) = self.current_state_var.take() - && !state_var.name.is_empty() - { - self.state_vars.push(state_var); - } - - if let Some(liveness) = self.current_liveness.take() - && !liveness.name.is_empty() - { - self.liveness_props.push(liveness); - } - } - - fn flush_integration(&mut self) { - if let Some(integration) = self.current_integration.take() - && !integration.name.is_empty() - { - self.integrations.push(integration); - } - } - - fn start_section(&mut self, section: Section) -> bool { - self.flush_items(); - self.current_section = section; - true - } - - fn start_state_section(&mut self) -> bool { - self.flush_items(); - self.current_state_var = Some(StateVar { - name: String::new(), - var_type: "string".into(), - initial: String::new(), - overflow_inline_max_bytes: None, - overflow_ttl_seconds: None, - query_indexed: None, - }); - self.current_section = Section::State; - true - } - - fn start_action_section(&mut self) -> bool { - self.flush_items(); - self.current_action = Some(Action { - name: String::new(), - kind: "internal".into(), - from: Vec::new(), - to: None, - guard: Vec::new(), - effect: Vec::new(), - params: Vec::new(), - hint: None, - record_parent_event: true, - triggers: Vec::new(), - cedar_gate: None, - sub_writes: Vec::new(), - }); - self.current_section = Section::Action; - true - } - - fn start_invariant_section(&mut self) -> bool { - self.flush_items(); - self.current_invariant = Some(Invariant { - name: String::new(), - when: Vec::new(), - assert: String::new(), - }); - self.current_section = Section::Invariant; - true - } - - fn start_liveness_section(&mut self) -> bool { - self.flush_items(); - self.flush_integration(); - self.current_liveness = Some(Liveness { - name: String::new(), - from: Vec::new(), - reaches: Vec::new(), - has_actions: None, - }); - self.current_section = Section::Liveness; - true - } - - fn start_integration_section(&mut self) -> bool { - self.flush_items(); - self.flush_integration(); - self.current_integration = Some(Integration { - name: String::new(), - trigger: String::new(), - integration_type: "webhook".to_string(), - module: None, - on_success: None, - on_failure: None, - llm: false, - config: std::collections::BTreeMap::new(), - }); - self.current_section = Section::Integration; - true - } - - fn start_webhook_section(&mut self) -> bool { - self.start_passthrough_section(Section::Webhook) - } - - fn start_passthrough_section(&mut self, section: Section) -> bool { - self.flush_items(); - self.flush_integration(); - self.current_section = section; - true - } -} - -/// Parse TOML into an Automaton struct. -/// -/// This is a minimal parser that handles the subset of TOML we use: -/// - `[automaton]` table with name, states, initial -/// - `[[action]]` array of tables -/// - `[[invariant]]` array of tables -/// - Simple key = "value" and key = ["array"] syntax +/// Parse one complete TOML document into the canonical automaton schema. pub(super) fn parse_toml_to_automaton(input: &str) -> Result { - let mut state = ParseState::default(); - let logical_lines = join_multiline_arrays(input); - - for line in logical_lines { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - - if state.enter_section(trimmed) { - continue; - } - - if let Some((key, value)) = parse_kv(trimmed) { - state.apply_kv(key, value)?; - } - } - - let mut automaton = state.finish(input)?; - // Field invariants use nested inline-table predicates that the hand-rolled - // parser does not handle, so delegate to serde. Unlike webhooks and agent - // triggers, parse errors are surfaced — a silently-dropped field invariant - // means the constraint is not enforced, which is a safety bug. - automaton.field_invariants = extract_field_invariants(input)?; - // ADR-0049: state_timeouts use nested params tables; parse via serde in - // an isolated pass. Errors are propagated — a silently-dropped timeout - // would mean a trap state at runtime. - automaton.state_timeouts = extract_state_timeouts(input)?; - // ADR-0153: [[key]] unique-key declarations; serde-extracted like timeouts. - // A silently-dropped key would mean the declared access path is not indexed. - automaton.keys = extract_keys(input)?; - // ADR-0155: [[vector]] access-path declarations; serde-extracted like keys. - // A silently-dropped vector path would leave similarity unindexed. - automaton.vectors = extract_vectors(input)?; - // ADR-0051: optional [admission] block. - automaton.admission = extract_admission(input)?; - Ok(automaton) -} - -/// Extract `[[webhook]]` sections from TOML source via serde. -/// -/// The hand-written parser does not handle `[[webhook]]` sections, so -/// we do a second pass with `toml::from_str` to deserialize them. -fn extract_webhooks(source: &str) -> Vec { - #[derive(serde::Deserialize)] - struct WebhookWrapper { - #[serde(default, rename = "webhook")] - webhooks: Vec, - } - toml::from_str::(source) - .map(|w| w.webhooks) - .unwrap_or_default() -} - -/// Extract nested `[[action.triggers]]` sections via serde (ADR-0046). -/// -/// Returns a map from action name to the triggers declared under it. -/// The hand-rolled parser is unable to handle nested array-of-tables, -/// so we do a second pass with `toml::from_str` over only the `[[action]]` -/// sections. Errors are propagated: silently dropping malformed triggers -/// would change runtime orchestration behavior. -fn extract_action_triggers( - source: &str, -) -> Result>, AutomatonParseError> -{ - let slice = isolate_action_sections(source); - if slice.trim().is_empty() { - return Ok(std::collections::BTreeMap::new()); - } - - #[derive(serde::Deserialize)] - struct ActionTriggersWrapper { - #[serde(default, rename = "action")] - actions: Vec, - } - #[derive(serde::Deserialize)] - struct ActionSkeleton { - #[serde(default)] - name: String, - #[serde(default)] - triggers: Vec, - } - let wrapper: ActionTriggersWrapper = toml::from_str(&slice) - .map_err(|e| AutomatonParseError::Toml(format!("action.triggers: {e}")))?; - let mut map: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - for action in wrapper.actions { - if action.name.is_empty() || action.triggers.is_empty() { - continue; - } - map.entry(action.name).or_default().extend(action.triggers); - } - Ok(map) -} - -#[derive(Debug, Default)] -struct ParsedCompositeActionMetadata { - cedar_gate: Option, - sub_writes: Vec, -} - -/// Extract nested `[[action.cedar_gate]]` and `[[action.sub_writes]]` -/// sections via serde (ADR-0040). -fn extract_action_composite_metadata( - source: &str, -) -> Result, AutomatonParseError> -{ - let slice = isolate_action_sections(source); - if slice.trim().is_empty() { - return Ok(std::collections::BTreeMap::new()); - } - - #[derive(serde::Deserialize)] - struct ActionCompositeWrapper { - #[serde(default, rename = "action")] - actions: Vec, - } - #[derive(serde::Deserialize)] - struct ActionSkeleton { - #[serde(default)] - name: String, - #[serde(default)] - cedar_gate: Vec, - #[serde(default)] - sub_writes: Vec, - } - let wrapper: ActionCompositeWrapper = toml::from_str(&slice) - .map_err(|e| AutomatonParseError::Toml(format!("action composite metadata: {e}")))?; - let mut map: std::collections::BTreeMap = - std::collections::BTreeMap::new(); - for action in wrapper.actions { - if action.name.is_empty() || (action.cedar_gate.is_empty() && action.sub_writes.is_empty()) - { - continue; - } - let metadata = map.entry(action.name).or_default(); - if let Some(gate) = action.cedar_gate.into_iter().next() { - metadata.cedar_gate = Some(gate); - } - metadata.sub_writes.extend(action.sub_writes); - } - Ok(map) -} - -/// Extract `[[field_invariant]]` sections from TOML source via serde. -/// -/// The hand-written parser does not handle nested inline-table predicates, -/// so we delegate to `toml::from_str` in a second pass. Unlike `extract_webhooks` -/// and `extract_agent_triggers`, parse errors here are propagated — a silently -/// dropped field invariant would mean the constraint is not enforced at -/// runtime, which is worse than a loud parse failure. -/// -/// To keep this resilient against unrelated TOML quirks elsewhere in the -/// source (e.g. duplicate keys in integration config that a strict -/// `toml::from_str` on the whole file would reject), we first slice out -/// only the `[[field_invariant]]` sections and parse just those. -fn extract_field_invariants( - source: &str, -) -> Result, AutomatonParseError> { - let slice = isolate_field_invariant_sections(source); - if slice.trim().is_empty() { - return Ok(Vec::new()); - } - - #[derive(serde::Deserialize)] - struct FieldInvariantWrapper { - #[serde(default, rename = "field_invariant")] - field_invariants: Vec, - } - toml::from_str::(&slice) - .map(|w| w.field_invariants) - .map_err(|e| AutomatonParseError::Toml(format!("field_invariant: {e}"))) -} - -/// Extract the optional `[admission]` block from TOML source via serde -/// (ADR-0051). -/// -/// Only one admission block is allowed per entity. The block lives at the -/// top level and accepts inline-table overrides, so serde handles it -/// entirely — the hand-rolled parser would need separate handling for the -/// `max_concurrent_actions = { ... }` inline table otherwise. -fn extract_admission(source: &str) -> Result, AutomatonParseError> { - let slice = isolate_single_table(source, "[admission]"); - if slice.trim().is_empty() { - return Ok(None); - } - - #[derive(serde::Deserialize)] - struct AdmissionWrapper { - admission: super::types::Admission, - } - toml::from_str::(&slice) - .map(|w| Some(w.admission)) - .map_err(|e| AutomatonParseError::Toml(format!("admission: {e}"))) + toml::from_str(input).map_err(|error| AutomatonParseError::Toml(error.to_string())) } - -/// Return a minimal TOML document containing only the single-table -/// `[header]` block (e.g., `[admission]`) from `source`. Other top-level -/// sections are skipped. Used for single-instance configuration blocks -/// where array-of-tables semantics do not apply. -fn isolate_single_table(source: &str, marker: &str) -> String { - let mut out = String::new(); - let mut inside = false; - for line in source.lines() { - let trimmed = line.trim_start(); - let is_header = trimmed.starts_with('['); - if is_header { - inside = trimmed.starts_with(marker); - if inside { - out.push_str(marker); - out.push('\n'); - } - continue; - } - if inside { - out.push_str(line); - out.push('\n'); - } - } - out -} - -/// Return a minimal TOML document containing only the sections with the -/// given `marker` header (e.g. `"[[state_timeout]]"`) from `source`. Other -/// top-level sections are skipped; content inside target sections is copied -/// verbatim so inline tables (`params = { ... }`) parse correctly. -fn isolate_sections(source: &str, marker: &str) -> String { - let mut out = String::new(); - let mut inside = false; - for line in source.lines() { - let trimmed = line.trim_start(); - let is_header = trimmed.starts_with('['); - if is_header { - inside = trimmed.starts_with(marker); - if inside { - out.push_str(marker); - out.push('\n'); - } - continue; - } - if inside { - out.push_str(line); - out.push('\n'); - } - } - out -} - -/// Return a minimal TOML document containing only `[[action]]` sections and -/// their nested `[[action.*]]` tables from `source`. -fn isolate_action_sections(source: &str) -> String { - let mut out = String::new(); - let mut inside = false; - for line in source.lines() { - let trimmed = line.trim_start(); - let is_header = trimmed.starts_with('['); - if is_header { - inside = trimmed.starts_with("[[action]]") - || trimmed.starts_with("[[action.") - || trimmed.starts_with("[action."); - if inside { - out.push_str(trimmed); - out.push('\n'); - } - continue; - } - if inside { - out.push_str(line); - out.push('\n'); - } - } - out -} - -/// Extract `[[state_timeout]]` sections from TOML source via serde -/// (ADR-0049). -/// -/// Uses the same isolation pattern as `extract_field_invariants` so -/// unrelated TOML quirks in other sections cannot break parsing. Errors -/// are propagated — a silently dropped state timeout would mean a -/// declared liveness contract is not enforced at runtime. -fn extract_state_timeouts( - source: &str, -) -> Result, AutomatonParseError> { - let slice = isolate_sections(source, "[[state_timeout]]"); - if slice.trim().is_empty() { - return Ok(Vec::new()); - } - - #[derive(serde::Deserialize)] - struct StateTimeoutWrapper { - #[serde(default, rename = "state_timeout")] - state_timeouts: Vec, - } - toml::from_str::(&slice) - .map(|w| w.state_timeouts) - .map_err(|e| AutomatonParseError::Toml(format!("state_timeout: {e}"))) -} - -/// Extract `[[key]]` unique-key declarations from TOML source via serde -/// (ADR-0153). Same isolation pattern as `extract_state_timeouts`. Errors are -/// propagated — a silently-dropped key would leave a declared access path -/// unindexed, re-opening the negative-existence scan (the 413, ARN-68). -fn extract_keys(source: &str) -> Result, AutomatonParseError> { - let slice = isolate_sections(source, "[[key]]"); - if slice.trim().is_empty() { - return Ok(Vec::new()); - } - - #[derive(serde::Deserialize)] - struct KeyWrapper { - #[serde(default, rename = "key")] - keys: Vec, - } - toml::from_str::(&slice) - .map(|w| w.keys) - .map_err(|e| AutomatonParseError::Toml(format!("key: {e}"))) -} - -/// Extract `[[vector]]` access-path declarations from TOML source via serde -/// (ADR-0155). Same isolation pattern as `extract_keys`. Errors are propagated — -/// a silently-dropped vector path would leave similarity unindexed while the spec -/// author believes `Temper.Nearest` will work. -fn extract_vectors(source: &str) -> Result, AutomatonParseError> { - let slice = isolate_sections(source, "[[vector]]"); - if slice.trim().is_empty() { - return Ok(Vec::new()); - } - - #[derive(serde::Deserialize)] - struct VectorWrapper { - #[serde(default, rename = "vector")] - vectors: Vec, - } - toml::from_str::(&slice) - .map(|w| w.vectors) - .map_err(|e| AutomatonParseError::Toml(format!("vector: {e}"))) -} - -/// Return a minimal TOML document containing only the `[[field_invariant]]` -/// sections from `source`. Any other top-level section is skipped. -/// -/// A section starts at a line whose trimmed form is `[[field_invariant]]` -/// and ends at the next line whose trimmed form begins with `[` (either a -/// new array-of-tables or a regular table header). Lines inside a section -/// are copied verbatim; comment and blank lines outside any field-invariant -/// section are dropped. -fn isolate_field_invariant_sections(source: &str) -> String { - let mut out = String::new(); - let mut inside = false; - for line in source.lines() { - let trimmed = line.trim_start(); - let is_header = trimmed.starts_with('['); - if is_header { - inside = trimmed.starts_with("[[field_invariant]]"); - if inside { - out.push_str("[[field_invariant]]\n"); - } - continue; - } - if inside { - out.push_str(line); - out.push('\n'); - } - } - out -} - -#[cfg(test)] -#[path = "tests.rs"] -mod tests; diff --git a/crates/temper-spec/src/automaton/toml_parser/tests.rs b/crates/temper-spec/src/automaton/toml_parser/tests.rs deleted file mode 100644 index c72b03413..000000000 --- a/crates/temper-spec/src/automaton/toml_parser/tests.rs +++ /dev/null @@ -1,476 +0,0 @@ -use super::inline::{parse_inline_fields, split_inline_tables}; -use super::*; - -#[test] -fn parse_kv_simple() { - let (key, value) = parse_kv("name = \"Order\"").unwrap(); - assert_eq!(key, "name"); - assert_eq!(value, "Order"); -} - -#[test] -fn parse_kv_no_equals() { - assert!(parse_kv("no_equals_here").is_none()); -} - -#[test] -fn extracts_declared_unique_keys() { - // ADR-0153: [[key]] declares an alternate (unique) key the kernel indexes. - let src = r#" -[automaton] -name = "File" -states = ["Created", "Ready"] -initial = "Created" - -[[key]] -name = "path" -properties = ["WorkspaceId", "Path"] - -[[key]] -name = "id" -properties = ["Id"] -"#; - let keys = extract_keys(src).expect("extract keys"); - assert_eq!(keys.len(), 2); - assert_eq!(keys[0].name, "path"); - assert_eq!(keys[0].properties, vec!["WorkspaceId", "Path"]); - assert_eq!(keys[1].name, "id"); - assert_eq!(keys[1].properties, vec!["Id"]); -} - -#[test] -fn extract_keys_empty_when_no_key_blocks() { - let src = "[automaton]\nname = \"File\"\nstates = [\"Created\"]\ninitial = \"Created\"\n"; - assert!(extract_keys(src).expect("extract keys").is_empty()); -} - -#[test] -fn extracts_declared_vector_paths() { - // ADR-0155: [[vector]] declares a vector access path the kernel indexes. - let src = r#" -[automaton] -name = "DesignLanguage" -states = ["Draft", "Published"] -initial = "Draft" - -[[vector]] -name = "taste" -property = "taste_vector" -model_property = "taste_vector_model" -dims = 384 -metric = "cosine" -"#; - let vectors = extract_vectors(src).expect("extract vectors"); - assert_eq!(vectors.len(), 1); - assert_eq!(vectors[0].name, "taste"); - assert_eq!(vectors[0].property, "taste_vector"); - assert_eq!(vectors[0].model_property, "taste_vector_model"); - assert_eq!(vectors[0].dims, 384); - assert_eq!(vectors[0].metric, "cosine"); -} - -#[test] -fn extract_vectors_empty_when_no_vector_blocks() { - let src = "[automaton]\nname = \"File\"\nstates = [\"Created\"]\ninitial = \"Created\"\n"; - assert!(extract_vectors(src).expect("extract vectors").is_empty()); -} - -#[test] -fn parse_kv_trims_whitespace() { - let (key, value) = parse_kv(" key = \"value\" ").unwrap(); - assert_eq!(key, "key"); - assert_eq!(value, "value"); -} - -#[test] -fn parse_string_array_simple() { - let arr = parse_string_array("[\"Draft\", \"Active\", \"Done\"]"); - assert_eq!(arr, vec!["Draft", "Active", "Done"]); -} - -#[test] -fn parse_string_array_single_value() { - let arr = parse_string_array("\"Active\""); - assert_eq!(arr, vec!["Active"]); -} - -#[test] -fn parse_string_array_empty_brackets() { - let arr = parse_string_array("[]"); - assert!(arr.is_empty()); -} - -#[test] -fn split_inline_tables_two_items() { - let result = split_inline_tables("{a = 1}, {b = 2}"); - assert_eq!(result.len(), 2); - assert_eq!(result[0], "{a = 1}"); - assert_eq!(result[1], "{b = 2}"); -} - -#[test] -fn split_inline_tables_empty() { - let result = split_inline_tables(""); - assert!(result.is_empty()); -} - -#[test] -fn parse_inline_fields_simple() { - let map = parse_inline_fields("type = \"schedule\", action = \"Refresh\""); - assert_eq!(map.get("type").unwrap(), "schedule"); - assert_eq!(map.get("action").unwrap(), "Refresh"); -} - -#[test] -fn parse_inline_fields_keeps_nested_arrays_together() { - let map = parse_inline_fields( - "type = \"cross_entity_state\", required_status = [\"Draft\", \"Ready\"]", - ); - assert_eq!(map.get("type").unwrap(), "cross_entity_state"); - assert_eq!( - map.get("required_status").unwrap(), - "[\"Draft\", \"Ready\"]" - ); -} - -#[test] -fn parse_inline_fields_empty() { - let map = parse_inline_fields(""); - assert!(map.is_empty()); -} - -#[test] -fn join_multiline_single_line() { - let result = join_multiline_arrays("key = [\"a\", \"b\"]"); - assert_eq!(result.len(), 1); - assert_eq!(result[0], "key = [\"a\", \"b\"]"); -} - -#[test] -fn join_multiline_continuation() { - let input = "effect = [\n { var = \"x\" },\n]"; - let result = join_multiline_arrays(input); - assert_eq!(result.len(), 1); - assert!(result[0].contains("effect = [")); - assert!(result[0].contains(']')); -} - -#[test] -fn join_multiline_no_brackets() { - let input = "name = \"Test\"\ninitial = \"Draft\""; - let result = join_multiline_arrays(input); - assert_eq!(result.len(), 2); -} - -#[test] -fn guard_gt() { - let g = parse_guard_clause("items > 3").unwrap(); - assert!(matches!(g, Guard::MinCount { ref var, min: 4 } if var == "items")); -} - -#[test] -fn guard_gte() { - let g = parse_guard_clause("items >= 5").unwrap(); - assert!(matches!(g, Guard::MinCount { ref var, min: 5 } if var == "items")); -} - -#[test] -fn guard_lt() { - let g = parse_guard_clause("items < 10").unwrap(); - assert!(matches!(g, Guard::MaxCount { ref var, max: 10 } if var == "items")); -} - -#[test] -fn guard_lte() { - let g = parse_guard_clause("items <= 10").unwrap(); - assert!(matches!(g, Guard::MaxCount { ref var, max: 11 } if var == "items")); -} - -#[test] -fn guard_prefix_min() { - let g = parse_guard_clause("min items 3").unwrap(); - assert!(matches!(g, Guard::MinCount { ref var, min: 3 } if var == "items")); -} - -#[test] -fn guard_prefix_max() { - let g = parse_guard_clause("max items 10").unwrap(); - assert!(matches!(g, Guard::MaxCount { ref var, max: 10 } if var == "items")); -} - -#[test] -fn guard_is_true() { - let g = parse_guard_clause("is_true approved").unwrap(); - assert!(matches!(g, Guard::IsTrue { ref var } if var == "approved")); -} - -#[test] -fn guard_list_contains() { - let g = parse_guard_clause("list_contains tags vip").unwrap(); - assert!( - matches!(g, Guard::ListContains { ref var, ref value } if var == "tags" && value == "vip") - ); -} - -#[test] -fn guard_list_length_min() { - let g = parse_guard_clause("list_length_min tags 2").unwrap(); - assert!(matches!(g, Guard::ListLengthMin { ref var, min: 2 } if var == "tags")); -} - -#[test] -fn guard_bare_boolean() { - let g = parse_guard_clause("has_mutation").unwrap(); - assert!(matches!(g, Guard::IsTrue { ref var } if var == "has_mutation")); -} - -#[test] -fn guard_negation_prefix() { - let g = parse_guard_clause("!needs_approval").unwrap(); - assert!(matches!(g, Guard::IsFalse { ref var } if var == "needs_approval")); -} - -#[test] -fn guard_is_false_prefix() { - let g = parse_guard_clause("is_false budget_exhausted").unwrap(); - assert!(matches!(g, Guard::IsFalse { ref var } if var == "budget_exhausted")); -} - -#[test] -fn guard_unsupported_syntax() { - assert!(parse_guard_clause("two words bad").is_err()); -} - -#[test] -fn parses_composite_action_metadata() { - let input = r#" -[automaton] -name = "Repository" -states = ["Active"] -initial = "Active" - -[[action]] -name = "IngestPack" -kind = "Composite" -from = ["Active"] -to = "Active" -params = ["PackBytes"] - -[[action.cedar_gate]] -principal = "request.principal" -resource = "this" -action = "Repository::IngestPack" - -[[action.sub_writes]] -target_entity = "Blob" -action = "Create" -generated_from = "pack_bytes" - -[[action.sub_writes]] -target_entity = "Ref" -action = "Update" -generated_from = "ref_updates" -"#; - - let parsed = parse_toml_to_automaton(input).unwrap(); - let action = parsed - .actions - .iter() - .find(|action| action.name == "IngestPack") - .unwrap(); - - assert_eq!(action.kind, "Composite"); - assert_eq!( - action.cedar_gate.as_ref().map(|gate| gate.action.as_str()), - Some("Repository::IngestPack") - ); - assert_eq!(action.sub_writes.len(), 2); - assert_eq!(action.sub_writes[0].target_entity, "Blob"); - assert_eq!(action.sub_writes[1].action, "Update"); -} - -// --- ADR-0049: [[state_timeout]] parsing -------------------------------- - -const SESSION_SPEC_WITH_TIMEOUTS: &str = r#" -[automaton] -name = "Session" -states = ["Created", "Provisioning", "Running", "Completed", "Failed", "WaitingForApproval"] -initial = "Created" -allow_indefinite_states = ["WaitingForApproval"] - -[[action]] -name = "Configure" -from = ["Created"] -to = "Provisioning" - -[[action]] -name = "TimeoutFail" -from = [] -to = "Failed" -params = ["error_message"] - -[[state_timeout]] -state = "Provisioning" -after_seconds = 180 -on_timeout = "TimeoutFail" -reset_on = ["Heartbeat"] -params = { error_message = "provisioning did not complete within 180s" } - -[[state_timeout]] -state = "Running" -after_seconds = 300 -on_timeout = "TimeoutFail" -max_occurrences = 3 -"#; - -#[test] -fn state_timeout_parses_all_fields() { - let auto = parse_toml_to_automaton(SESSION_SPEC_WITH_TIMEOUTS).unwrap(); - assert_eq!(auto.state_timeouts.len(), 2); - - let provisioning = &auto.state_timeouts[0]; - assert_eq!(provisioning.state, "Provisioning"); - assert_eq!(provisioning.after_seconds, 180); - assert_eq!(provisioning.on_timeout, "TimeoutFail"); - assert_eq!(provisioning.max_occurrences, 1, "default should be 1"); - assert_eq!(provisioning.reset_on, vec!["Heartbeat".to_string()]); - assert_eq!( - provisioning.params.get("error_message").map(|s| s.as_str()), - Some("provisioning did not complete within 180s") - ); -} - -#[test] -fn state_timeout_max_occurrences_override() { - let auto = parse_toml_to_automaton(SESSION_SPEC_WITH_TIMEOUTS).unwrap(); - let running = &auto.state_timeouts[1]; - assert_eq!(running.state, "Running"); - assert_eq!(running.max_occurrences, 3); - assert!( - running.reset_on.is_empty(), - "reset_on omitted should default to empty" - ); - assert!(running.params.is_empty()); -} - -#[test] -fn allow_indefinite_states_parses_from_automaton_block() { - let auto = parse_toml_to_automaton(SESSION_SPEC_WITH_TIMEOUTS).unwrap(); - assert_eq!( - auto.automaton.allow_indefinite_states, - vec!["WaitingForApproval".to_string()] - ); -} - -#[test] -fn state_timeout_absent_yields_empty_vec() { - let minimal = r#" -[automaton] -name = "Trivial" -states = ["Idle"] -initial = "Idle" -"#; - let auto = parse_toml_to_automaton(minimal).unwrap(); - assert!(auto.state_timeouts.is_empty()); - assert!(auto.automaton.allow_indefinite_states.is_empty()); -} - -#[test] -fn state_timeout_isolation_ignores_other_sections() { - // Ensures extract_state_timeouts' isolation doesn't pick up keys that - // happen to share a name with state_timeout fields in other sections. - let spec = r#" -[automaton] -name = "X" -states = ["A", "B"] -initial = "A" - -[[state]] -name = "state" -type = "string" -initial = "irrelevant" - -[[action]] -name = "OnTimeout" -from = ["A"] -to = "B" -params = ["error_message"] - -[[integration]] -name = "noop" -trigger = "noop" -type = "webhook" - -[[state_timeout]] -state = "A" -after_seconds = 10 -on_timeout = "OnTimeout" -"#; - let auto = parse_toml_to_automaton(spec).unwrap(); - assert_eq!(auto.state_timeouts.len(), 1); - assert_eq!(auto.state_timeouts[0].state, "A"); -} - -#[test] -fn admission_block_parses_inline_action_map() { - let spec = r#" -[automaton] -name = "X" -states = ["A"] -initial = "A" - -[admission] -max_concurrent_creates = 5 -max_concurrent_actions = { "Submit" = 3, "Configure" = 10 } -queue_depth = 75 -queue_timeout_seconds = 20 -"#; - let auto = parse_toml_to_automaton(spec).unwrap(); - let admission = auto.admission.as_ref().expect("admission block parsed"); - assert_eq!(admission.max_concurrent_creates, Some(5)); - assert_eq!( - admission.max_concurrent_actions.get("Submit").copied(), - Some(3) - ); - assert_eq!( - admission.max_concurrent_actions.get("Configure").copied(), - Some(10) - ); - assert_eq!(admission.queue_depth, Some(75)); - assert_eq!(admission.queue_timeout_seconds, Some(20)); -} - -#[test] -fn admission_block_absent_yields_none() { - let minimal = r#" -[automaton] -name = "Trivial" -states = ["Idle"] -initial = "Idle" -"#; - let auto = parse_toml_to_automaton(minimal).unwrap(); - assert!(auto.admission.is_none()); -} - -#[test] -fn state_timeout_malformed_surfaces_error() { - // `after_seconds = "not a number"` should produce a serde error, - // not a silent drop. - let spec = r#" -[automaton] -name = "Bad" -states = ["A"] -initial = "A" - -[[state_timeout]] -state = "A" -after_seconds = "not a number" -on_timeout = "X" -"#; - let err = parse_toml_to_automaton(spec).expect_err("malformed after_seconds must surface"); - let msg = err.to_string(); - assert!( - msg.contains("state_timeout"), - "error should be scoped to state_timeout: {msg}" - ); -} diff --git a/crates/temper-spec/src/automaton/types.rs b/crates/temper-spec/src/automaton/types.rs index 34cf56049..1373f20b9 100644 --- a/crates/temper-spec/src/automaton/types.rs +++ b/crates/temper-spec/src/automaton/types.rs @@ -9,8 +9,11 @@ use std::collections::BTreeMap; use super::field_invariant::FieldInvariant; +mod deserialization; + /// A complete I/O Automaton specification for a single entity type. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Automaton { /// Automaton metadata. pub automaton: AutomatonMeta, @@ -69,6 +72,7 @@ pub struct Automaton { /// O(log n) present/absent reads; the canonical key hash uses `properties` in /// declared order. Multiple keys on one entity are distinguished by `name`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct KeyDecl { /// Identifier for this key (the `key_name` in `entity_key_index`). pub name: String, @@ -84,6 +88,7 @@ pub struct KeyDecl { /// exact-scan kNN through `Temper.Nearest`. `metric` is one of `cosine`, `dot`, /// `l2`. Multiple vector paths on one entity are distinguished by `name`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct VectorDecl { /// Identifier for this path (the `decl_name` in `entity_vector_index` and the /// `decl=` argument to `Temper.Nearest`). @@ -101,9 +106,13 @@ pub struct VectorDecl { /// Automaton metadata. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AutomatonMeta { /// Entity name (e.g., "Order"). pub name: String, + /// Optional author-supplied schema version retained in canonical output. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, /// The status state space (all valid values). pub states: Vec, /// Initial status value. @@ -118,6 +127,7 @@ pub struct AutomatonMeta { /// A state variable declaration. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct StateVar { /// Variable name. pub name: String, @@ -147,7 +157,7 @@ pub struct StateVar { /// A parameter on an action — either a plain name (defaults to string type) /// or a typed declaration. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[serde(untagged)] pub enum ActionParam { Named(String), @@ -187,6 +197,7 @@ impl ActionParam { /// /// Each action has a precondition (guard) and effects (state changes). #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Action { /// Action name (e.g., "SubmitOrder"). pub name: String, @@ -199,10 +210,10 @@ pub struct Action { /// Effect: the target state after this action fires. pub to: Option, /// Additional guard conditions. - #[serde(default)] + #[serde(default, deserialize_with = "super::toml_parser::deserialize_guards")] pub guard: Vec, /// Effects beyond state change. - #[serde(default)] + #[serde(default, deserialize_with = "super::toml_parser::deserialize_effects")] pub effect: Vec, /// Parameters this action accepts. #[serde(default)] @@ -221,7 +232,11 @@ pub struct Action { #[serde(default, rename = "triggers")] pub triggers: Vec, /// Composite-action Cedar gate declaration (ADR-0040). - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "super::toml_parser::deserialize_cedar_gate" + )] pub cedar_gate: Option, /// Declared sub-write contract for Composite actions (ADR-0040). #[serde(default, rename = "sub_writes")] @@ -238,6 +253,7 @@ fn default_record_parent_event() -> bool { /// The single Cedar gate evaluated for a Composite action. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct CompositeCedarGate { /// Principal expression, usually `request.principal`. pub principal: String, @@ -249,6 +265,7 @@ pub struct CompositeCedarGate { /// Declared write shape emitted by a Composite action. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct SubWriteSpec { /// Target entity type receiving the write. pub target_entity: String, @@ -261,7 +278,7 @@ pub struct SubWriteSpec { /// A guard condition (precondition predicate on pre-state). #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] +#[serde(tag = "type", deny_unknown_fields)] pub enum Guard { /// Status must be one of these values. #[serde(rename = "state_in")] @@ -326,7 +343,7 @@ pub enum Guard { } /// An effect (state change in the post-state). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[serde(tag = "type")] pub enum Effect { /// Increment a counter variable. @@ -385,6 +402,7 @@ pub enum Effect { /// A safety invariant. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Invariant { /// Invariant name. pub name: String, @@ -401,6 +419,7 @@ pub struct Invariant { /// Liveness properties assert that something "eventually happens" — a state /// is eventually reached, or deadlock never occurs from certain states. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Liveness { /// Property name. pub name: String, @@ -420,7 +439,7 @@ pub struct Liveness { /// Integrations declare that a state machine event should trigger an external /// action (e.g., a webhook call or WASM module invocation). They are metadata /// only — they do not affect state transitions or verification. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct Integration { /// Integration name (e.g., "notify_fulfillment", "charge_payment"). pub name: String, @@ -445,7 +464,7 @@ pub struct Integration { pub llm: bool, /// Arbitrary config passed to the WASM module at invocation time. /// Common keys: `url`, `method`, `headers`. - #[serde(flatten, default)] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub config: BTreeMap, } @@ -469,6 +488,7 @@ fn default_query_param() -> String { /// call back into Temper, triggering entity actions. They are metadata-only /// — they do not affect verification. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Webhook { /// Webhook name (e.g., "oauth_callback"). pub name: String, @@ -501,6 +521,7 @@ pub struct Webhook { /// Declares that another entity's status should be available in the Cedar /// authorization context when evaluating policies for this entity type. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ContextEntityDecl { /// Label for this context entity (e.g., "parent_agent"). pub name: String, @@ -530,6 +551,7 @@ pub struct ContextEntityDecl { /// and wires `state` into the target action's `from` list if it is not /// already present. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct StateTimeout { /// The state whose entry arms the timer. Must be a declared state. pub state: String, @@ -572,6 +594,7 @@ fn default_one() -> u32 { /// All fields are optional. A missing admission block means no gating for /// that entity type (backward compatible). #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct Admission { /// Max concurrent pending `Create` (entity-instantiation) calls per /// tenant. `None` = unlimited. @@ -638,7 +661,7 @@ pub enum TriggerLiveness { /// How to resolve the target entity ID for a `kind = "entity"` trigger. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum TargetResolver { /// Read the target entity ID from a field on the source entity. Field { @@ -671,7 +694,7 @@ pub enum TargetResolver { /// (sync variants) or another entity's current state (`CrossEntityStateIn`). /// Guard-skipped triggers do not emit a dispatch record — they never fired. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum TriggerGuard { /// Source field equals the given JSON value. FieldEquals { @@ -765,6 +788,7 @@ impl TriggerGuard { /// - `Adapter`: requires `adapter` or `adapter_type`. /// - `Webhook`: requires `url` + `method`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] pub struct ActionTrigger { /// Human-readable name for logging and debugging. pub name: String, @@ -812,7 +836,7 @@ pub struct ActionTrigger { #[serde(default)] pub target_action: Option, /// Static parameters to pass to the target action. - #[serde(default)] + #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] pub params: serde_json::Value, /// Dynamic params: target-param-name → source-entity-field-name. /// diff --git a/crates/temper-spec/src/automaton/types/deserialization.rs b/crates/temper-spec/src/automaton/types/deserialization.rs new file mode 100644 index 000000000..24cb487ec --- /dev/null +++ b/crates/temper-spec/src/automaton/types/deserialization.rs @@ -0,0 +1,223 @@ +//! Strict deserializers for schema types with narrow legacy compatibility. + +use serde::Deserialize; +use serde::de::{self, Deserializer}; +use std::collections::BTreeMap; + +use super::super::toml_parser::{deserialize_boolish, deserialize_copy_fields}; +use super::{ActionParam, Effect, Integration, default_param_type, default_webhook}; + +impl<'de> Deserialize<'de> for ActionParam { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + match toml::Value::deserialize(deserializer)? { + toml::Value::String(name) => Ok(Self::Named(name)), + toml::Value::Table(mut fields) => { + for key in fields.keys() { + if !matches!(key.as_str(), "name" | "type") { + return Err(de::Error::custom(format!( + "unknown action parameter field `{key}`" + ))); + } + } + let name = take_string_field(&mut fields, "name") + .map_err(de::Error::custom)? + .ok_or_else(|| de::Error::missing_field("name"))?; + let param_type = take_string_field(&mut fields, "type") + .map_err(de::Error::custom)? + .unwrap_or_else(default_param_type); + Ok(Self::Typed { name, param_type }) + } + value => Err(de::Error::custom(format!( + "action parameter must be a name string or typed table, got {value}" + ))), + } + } +} + +#[derive(Deserialize)] +#[serde(tag = "type", deny_unknown_fields)] +enum EffectDefinition { + #[serde(rename = "increment")] + Increment { + var: String, + #[serde(default)] + amount: Option, + }, + #[serde(rename = "decrement")] + Decrement { + var: String, + #[serde(default)] + amount: Option, + }, + #[serde(rename = "set_counter_from_param")] + SetCounterFromParam { + var: String, + #[serde(default)] + param: Option, + }, + #[serde(rename = "set_bool")] + SetBool { + var: String, + #[serde(deserialize_with = "deserialize_boolish")] + value: bool, + }, + #[serde(rename = "emit", alias = "emit_event")] + Emit { event: String }, + #[serde(rename = "list_append")] + ListAppend { + #[serde(alias = "list")] + var: String, + }, + #[serde(rename = "list_remove_at")] + ListRemoveAt { + #[serde(alias = "list")] + var: String, + }, + #[serde(rename = "trigger")] + Trigger { name: String }, + #[serde(rename = "schedule")] + Schedule { action: String, delay_seconds: u64 }, + #[serde(rename = "schedule_at")] + ScheduleAt { action: String, field: String }, + #[serde(rename = "spawn", alias = "spawn_entity")] + Spawn { + entity_type: String, + entity_id_source: String, + #[serde(default)] + initial_action: Option, + #[serde(default)] + store_id_in: Option, + #[serde(default, deserialize_with = "deserialize_copy_fields")] + copy_fields: Option>, + }, +} + +impl<'de> Deserialize<'de> for Effect { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(match EffectDefinition::deserialize(deserializer)? { + EffectDefinition::Increment { var, amount } => Self::Increment { var, amount }, + EffectDefinition::Decrement { var, amount } => Self::Decrement { var, amount }, + EffectDefinition::SetCounterFromParam { var, param } => { + let param = param.unwrap_or_else(|| var.clone()); + Self::SetCounterFromParam { var, param } + } + EffectDefinition::SetBool { var, value } => Self::SetBool { var, value }, + EffectDefinition::Emit { event } => Self::Emit { event }, + EffectDefinition::ListAppend { var } => Self::ListAppend { var }, + EffectDefinition::ListRemoveAt { var } => Self::ListRemoveAt { var }, + EffectDefinition::Trigger { name } => Self::Trigger { name }, + EffectDefinition::Schedule { + action, + delay_seconds, + } => Self::Schedule { + action, + delay_seconds, + }, + EffectDefinition::ScheduleAt { action, field } => Self::ScheduleAt { action, field }, + EffectDefinition::Spawn { + entity_type, + entity_id_source, + initial_action, + store_id_in, + copy_fields, + } => Self::Spawn { + entity_type, + entity_id_source, + initial_action, + store_id_in, + copy_fields, + }, + }) + } +} + +impl<'de> Deserialize<'de> for Integration { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = toml::Value::deserialize(deserializer)?; + let toml::Value::Table(mut fields) = value else { + return Err(de::Error::custom("integration must be a TOML table")); + }; + + let name = take_string_field(&mut fields, "name") + .map_err(de::Error::custom)? + .ok_or_else(|| de::Error::missing_field("name"))?; + let trigger = take_string_field(&mut fields, "trigger") + .map_err(de::Error::custom)? + .ok_or_else(|| de::Error::missing_field("trigger"))?; + let integration_type = take_string_field(&mut fields, "type") + .map_err(de::Error::custom)? + .unwrap_or_else(default_webhook); + let module = take_string_field(&mut fields, "module").map_err(de::Error::custom)?; + let on_success = take_string_field(&mut fields, "on_success").map_err(de::Error::custom)?; + let on_failure = take_string_field(&mut fields, "on_failure").map_err(de::Error::custom)?; + let llm = match fields.remove("llm") { + None => false, + Some(toml::Value::Boolean(value)) => value, + Some(toml::Value::String(value)) if value.eq_ignore_ascii_case("true") => true, + Some(toml::Value::String(value)) if value.eq_ignore_ascii_case("false") => false, + Some(value) => { + return Err(de::Error::custom(format!( + "integration field `llm` must be a boolean, got {value}" + ))); + } + }; + let mut config = match fields.remove("config") { + None => BTreeMap::new(), + Some(toml::Value::Table(config)) => config + .into_iter() + .map(|(key, value)| (key, integration_config_value(value))) + .collect(), + Some(value) => { + BTreeMap::from([("config".to_string(), integration_config_value(value))]) + } + }; + for (key, value) in fields { + if config + .insert(key.clone(), integration_config_value(value)) + .is_some() + { + return Err(de::Error::custom(format!( + "integration config key `{key}` declared twice" + ))); + } + } + + Ok(Self { + name, + trigger, + integration_type, + module, + on_success, + on_failure, + llm, + config, + }) + } +} + +fn take_string_field( + fields: &mut toml::map::Map, + key: &str, +) -> Result, String> { + match fields.remove(key) { + None => Ok(None), + Some(toml::Value::String(value)) => Ok(Some(value)), + Some(value) => Err(format!("field `{key}` must be a string, got {value}")), + } +} + +fn integration_config_value(value: toml::Value) -> String { + match value { + toml::Value::String(value) => value, + value => value.to_string(), + } +} diff --git a/crates/temper-spec/src/bin/verify_specs.rs b/crates/temper-spec/src/bin/verify_specs.rs index 34af2ab5c..dc5537d09 100644 --- a/crates/temper-spec/src/bin/verify_specs.rs +++ b/crates/temper-spec/src/bin/verify_specs.rs @@ -17,6 +17,7 @@ //! //! ```sh //! cargo run --quiet --bin verify_specs -- os-apps crates +//! cargo run --quiet --bin verify_specs -- --syntax-only path/to/entity.ioa.toml //! ``` use std::path::{Path, PathBuf}; @@ -25,12 +26,19 @@ use std::process::ExitCode; use temper_spec::automaton::{LivenessEnforcement, parse_automaton_with_liveness}; fn main() -> ExitCode { - let args: Vec = std::env::args().skip(1).collect(); + let mut args: Vec = std::env::args().skip(1).collect(); + let liveness_mode = if args.first().is_some_and(|arg| arg == "--syntax-only") { + args.remove(0); + LivenessEnforcement::WarnOnly + } else { + LivenessEnforcement::Enforce + }; if args.is_empty() { eprintln!( - "usage: verify_specs [ ...]\n\ + "usage: verify_specs [--syntax-only] [ ...]\n\ Walks each directory recursively, parses every *.ioa.toml, and\n\ - enforces ADR-0050 liveness coverage. Exits non-zero on any failure." + enforces ADR-0050 liveness coverage unless --syntax-only is set.\n\ + Exits non-zero on any failure." ); return ExitCode::from(2); } @@ -44,17 +52,18 @@ fn main() -> ExitCode { eprintln!("verify_specs: path does not exist: {}", root.display()); return ExitCode::from(2); } - if let Err(e) = walk(&root, &mut specs_found, &mut failures) { + if let Err(e) = walk(&root, liveness_mode, &mut specs_found, &mut failures) { eprintln!("verify_specs: walk failed under {}: {e}", root.display()); return ExitCode::from(2); } } if failures.is_empty() { - println!( - "verify_specs: {} spec(s) passed (ADR-0050 liveness enforce mode)", - specs_found - ); + let mode = match liveness_mode { + LivenessEnforcement::WarnOnly => "schema validation mode", + LivenessEnforcement::Enforce => "ADR-0050 liveness enforce mode", + }; + println!("verify_specs: {specs_found} spec(s) passed ({mode})"); return ExitCode::SUCCESS; } @@ -75,12 +84,13 @@ fn main() -> ExitCode { fn walk( dir: &Path, + liveness_mode: LivenessEnforcement, specs_found: &mut u64, failures: &mut Vec<(PathBuf, String)>, ) -> std::io::Result<()> { if dir.is_file() { if is_ioa_spec(dir) { - check_spec(dir, specs_found, failures); + check_spec(dir, liveness_mode, specs_found, failures); } return Ok(()); } @@ -94,9 +104,9 @@ fn walk( { continue; } - walk(&path, specs_found, failures)?; + walk(&path, liveness_mode, specs_found, failures)?; } else if is_ioa_spec(&path) { - check_spec(&path, specs_found, failures); + check_spec(&path, liveness_mode, specs_found, failures); } } Ok(()) @@ -108,13 +118,18 @@ fn is_ioa_spec(path: &Path) -> bool { .is_some_and(|n| n.ends_with(".ioa.toml")) } -fn check_spec(path: &Path, specs_found: &mut u64, failures: &mut Vec<(PathBuf, String)>) { +fn check_spec( + path: &Path, + liveness_mode: LivenessEnforcement, + specs_found: &mut u64, + failures: &mut Vec<(PathBuf, String)>, +) { *specs_found += 1; let Ok(source) = std::fs::read_to_string(path) else { failures.push((path.to_path_buf(), "could not read file".to_string())); return; }; - if let Err(e) = parse_automaton_with_liveness(&source, LivenessEnforcement::Enforce) { + if let Err(e) = parse_automaton_with_liveness(&source, liveness_mode) { failures.push((path.to_path_buf(), e.to_string())); } } diff --git a/crates/temper-spec/tests/ioa_corpus.rs b/crates/temper-spec/tests/ioa_corpus.rs index c1a03d708..f0fce04a2 100644 --- a/crates/temper-spec/tests/ioa_corpus.rs +++ b/crates/temper-spec/tests/ioa_corpus.rs @@ -13,7 +13,7 @@ const SPEC_ROOTS: [&str; 6] = [ ]; #[test] -fn every_tracked_ioa_spec_parses_through_the_canonical_schema() { +fn every_tracked_ioa_spec_parses_and_round_trips_through_the_canonical_schema() { let workspace = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .and_then(Path::parent) @@ -24,15 +24,54 @@ fn every_tracked_ioa_spec_parses_through_the_canonical_schema() { } paths.sort(); paths.dedup(); - assert!(paths.len() >= 100, "expected the repository IOA corpus"); + assert!( + paths.len() >= 130, + "expected the full repository IOA corpus" + ); let mut failures = Vec::new(); for path in paths { let source = fs::read_to_string(&path) .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); - if let Err(error) = parse_automaton_with_liveness(&source, LivenessEnforcement::WarnOnly) { - let relative = path.strip_prefix(workspace).unwrap_or(&path); - failures.push(format!("{}: {error}", relative.display())); + let relative = path.strip_prefix(workspace).unwrap_or(&path); + let parsed = match parse_automaton_with_liveness(&source, LivenessEnforcement::WarnOnly) { + Ok(parsed) => parsed, + Err(error) => { + failures.push(format!("{}: initial parse: {error}", relative.display())); + continue; + } + }; + let canonical = match toml::to_string(&parsed) { + Ok(canonical) => canonical, + Err(error) => { + failures.push(format!( + "{}: canonical serialization: {error}", + relative.display() + )); + continue; + } + }; + let reparsed = + match parse_automaton_with_liveness(&canonical, LivenessEnforcement::WarnOnly) { + Ok(reparsed) => reparsed, + Err(error) => { + failures.push(format!( + "{}: canonical round-trip parse: {error}", + relative.display() + )); + continue; + } + }; + match toml::to_string(&reparsed) { + Ok(reserialized) if reserialized == canonical => {} + Ok(_) => failures.push(format!( + "{}: canonical serialization changed after round trip", + relative.display() + )), + Err(error) => failures.push(format!( + "{}: canonical round-trip serialization: {error}", + relative.display() + )), } } diff --git a/crates/temper-spec/tests/migration_differential.rs b/crates/temper-spec/tests/migration_differential.rs index 85477af92..3e870c721 100644 --- a/crates/temper-spec/tests/migration_differential.rs +++ b/crates/temper-spec/tests/migration_differential.rs @@ -245,6 +245,36 @@ fn read_current(repo_root: &Path, path: &str) -> String { .unwrap_or_else(|e| panic!("read {}: {e}", full_path.display())) } +/// Remove only declarations that the retired hand parser silently overwrote. +/// +/// The migration differential compares integration behavior in historical +/// files. Two of those snapshots contain unrelated duplicate declarations +/// that the canonical parser must reject, so the fixture repair removes the +/// overwritten copy while leaving the integration under test unchanged. +fn repair_strict_historical_fixture(path: &str, source: String) -> String { + match path { + "os-apps/evolution/evolution_run.ioa.toml" => source.replacen( + "temper_api_key = \"{secret:temper_api_key}\"\ntemper_api_key = \"{secret:temper_api_key}\"", + "temper_api_key = \"{secret:temper_api_key}\"", + 1, + ), + "os-apps/temper-agent/specs/temper_agent.ioa.toml" => { + const DUPLICATE: &str = "[[state]]\nname = \"temper_api_url\"\ntype = \"string\"\ninitial = \"http://127.0.0.1:3000\"\n"; + let first = source + .find(DUPLICATE) + .expect("historical fixture must contain the first state declaration"); + let second = source[first + DUPLICATE.len()..] + .find(DUPLICATE) + .map(|offset| first + DUPLICATE.len() + offset) + .expect("historical fixture must contain the duplicate state declaration"); + let mut repaired = source; + repaired.replace_range(second..second + DUPLICATE.len(), ""); + repaired + } + _ => source, + } +} + /// Integrations intentionally dropped (not just migrated) after the initial /// conversion. These are expected to be missing from the post-migration spec /// — the differential treats their absence as correct, not a regression. @@ -430,7 +460,7 @@ fn all_migrations_preserve_integrations() { }; checked += 1; - let old_src = git_show(&repo_root, sha, path); + let old_src = repair_strict_historical_fixture(path, git_show(&repo_root, sha, path)); let new_src = read_current(&repo_root, path); let old = match parse_automaton(&old_src) { diff --git a/docs/adrs/0171-canonical-ioa-schema-parser.md b/docs/adrs/0171-canonical-ioa-schema-parser.md index ce47f46c4..9d2d90f07 100644 --- a/docs/adrs/0171-canonical-ioa-schema-parser.md +++ b/docs/adrs/0171-canonical-ioa-schema-parser.md @@ -31,9 +31,11 @@ absent from the public `parse_automaton` result. ### Parse the complete document exactly once `temper-spec` will deserialize the entire source directly into the canonical -`Automaton` schema with `toml::from_str`. Section isolation and parse-again extractors -are removed. TOML syntax and duplicate-key failures retain the source spans reported by -the TOML deserializer. +`Automaton` schema with `toml::from_str`. Section isolation and whole-document +parse-again extractors are removed. TOML syntax and duplicate-key failures retain the +source spans reported by the TOML deserializer. Supported string-form guards and +effects remain field-local embedded languages; their compatibility decoders never +rescan unrelated document sections. **Why this approach**: one schema and one parse result make declaration consumption structural. A supported declaration is represented in the AST once; malformed source @@ -59,6 +61,10 @@ typed `Guard` and `Effect` schemas. Existing effect aliases and string booleans accepted where the current parser accepts them, but malformed values now return an error rather than defaulting or being omitted. +Parser-only tooling, including the pre-commit syntax gate, uses this same canonical +schema path without requiring an unrelated CSDL model. Full verification continues to +require and validate the CSDL model before advancing through its cascade. + ## Rollout Plan 1. Replace the parser and add regression coverage for unknown fields/tables, incomplete diff --git a/os-apps/temper-agent/specs/temper_agent.ioa.toml b/os-apps/temper-agent/specs/temper_agent.ioa.toml index 2f179bfe1..372594faa 100644 --- a/os-apps/temper-agent/specs/temper_agent.ioa.toml +++ b/os-apps/temper-agent/specs/temper_agent.ioa.toml @@ -104,11 +104,6 @@ name = "sandbox_id" type = "string" initial = "" -[[state]] -name = "temper_api_url" -type = "string" -initial = "http://127.0.0.1:3000" - [[state]] name = "file_manifest_id" type = "string" diff --git a/scripts/setup-hooks.sh b/scripts/setup-hooks.sh index 138f63e7b..6484461cc 100755 --- a/scripts/setup-hooks.sh +++ b/scripts/setup-hooks.sh @@ -1,11 +1,16 @@ #!/bin/bash # Item 15: Git Hook Installer -# Installs pre-commit, pre-push, and post-commit hooks into .git/hooks/ +# Installs pre-commit, pre-push, and post-commit hooks into Git's shared hooks +# directory, including when invoked from a linked worktree. # Idempotent — safe to run multiple times. set -euo pipefail WORKSPACE_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -HOOKS_DIR="$WORKSPACE_ROOT/.git/hooks" +GIT_COMMON_DIR="$(git -C "$WORKSPACE_ROOT" rev-parse --git-common-dir)" +if [[ "$GIT_COMMON_DIR" != /* ]]; then + GIT_COMMON_DIR="$WORKSPACE_ROOT/$GIT_COMMON_DIR" +fi +HOOKS_DIR="$GIT_COMMON_DIR/hooks" SOURCE_DIR="$WORKSPACE_ROOT/.claude/hooks" echo "=== Installing Git Hooks ===" diff --git a/test-fixtures/specs/process.ioa.toml b/test-fixtures/specs/process.ioa.toml index ffc141ee3..183dfc7b8 100644 --- a/test-fixtures/specs/process.ioa.toml +++ b/test-fixtures/specs/process.ioa.toml @@ -11,12 +11,6 @@ states = [ ] initial = "Created" -[automaton.timeouts] -BlockedInference = "120s" -BlockedToolCall = "60s" -BlockedCompaction = "30s" -BlockedApproval = "3600s" - [[state]] name = "turns" type = "counter" @@ -192,6 +186,26 @@ from = ["BlockedInference", "BlockedToolCall", "BlockedApproval", "BlockedCompac to = "Failed" effect = [{ type = "trigger", name = "notify_parent" }] +[[state_timeout]] +state = "BlockedInference" +after_seconds = 120 +on_timeout = "TimeoutExpired" + +[[state_timeout]] +state = "BlockedToolCall" +after_seconds = 60 +on_timeout = "TimeoutExpired" + +[[state_timeout]] +state = "BlockedCompaction" +after_seconds = 30 +on_timeout = "TimeoutExpired" + +[[state_timeout]] +state = "BlockedApproval" +after_seconds = 3600 +on_timeout = "TimeoutExpired" + # ─── Integrations ───────────────────────────────────────────────────────────── [[integration]]