Skip to content

fix(spec): reject malformed IOA safety content (ARN-214) - #392

Draft
rita-aga wants to merge 3 commits into
mainfrom
grok/arn-214-malformed-safety
Draft

fix(spec): reject malformed IOA safety content (ARN-214)#392
rita-aga wants to merge 3 commits into
mainfrom
grok/arn-214-malformed-safety

Conversation

@rita-aga

@rita-aga rita-aga commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes the silent-drop path in the IOA parser (ARN-214 / ADR-0179).

The hand-rolled line parser ignored unknown tables/fields and dropped unnamed safety blocks; webhook extraction turned parse failures into empty lists. Verification could not tell “not declared” from “parser discarded it.”

This PR parses each document once into the canonical Automaton schema with deny_unknown_fields, removes parallel section extractors, and fails closed with source spans.

Changes

  • Schema-backed toml::from_str path in temper-spec (drops hand-rolled grammar)
  • deny_unknown_fields on closed IOA records; compatibility deserializers for string guards/effects
  • Uniqueness validation for safety-relevant names; idempotent trigger-integration synthesis on round-trip
  • Fail-closed regressions (unknown table/field typos, truncated asserts, unnamed blocks, bad webhooks, duplicate keys)
  • Full-repo IOA corpus + migration differential coverage
  • Fixture fixes for specs that relied on silent drops (state_timeout form, effect aliases, etc.)
  • verify_specs --syntax-only for pre-commit schema checks

Test plan

  • RED commit: strictness tests expose silent omissions
  • GREEN: cargo test -p temper-spec241 lib + ioa_corpus + migration_differential green
  • Strictness module: 9/9 pass
  • CI on draft PR

Notes

  • ADR number is 0179 (independent of competitor 0171)
  • Pre-push workspace suite skipped on push due to multi-agent cargo contention; package-scoped verification is green locally

Greptile Summary

Replaces the hand-rolled IOA line-parser (≈750 LOC across mod.rs, effects.rs, guards.rs, inline.rs) with a single toml::from_str call backed by #[serde(deny_unknown_fields)] on every schema type. This closes the silent-drop path where unknown tables, misspelled fields, truncated assertions, and unnamed blocks were previously accepted without error.

  • Schema enforcement: deny_unknown_fields is added to Automaton, Action, Invariant, Liveness, Webhook, StateTimeout, KeyDecl, VectorDecl, FieldInvariant, Admission, ActionTrigger, and the tagged enums Guard/TargetResolver/TriggerGuard. Legacy string-form guards and effects are handled by purpose-built compatibility deserializers in the new compatibility.rs, preserving backward compatibility while keeping the closed schema.
  • Idempotent synthesis: expand_external_action_triggers switches from extend to an explicit (0,0)→push / (1,1)→skip / conflict→error loop, making parse→serialize→parse round-trips stable; the ioa_corpus test verifies this across 130+ specs.
  • Uniqueness validation: validate_unique_names is applied to every named entity type (states, actions, invariants, liveness, integrations, webhooks, context entities, field invariants, keys, vectors) before synthesis runs.

Confidence Score: 5/5

Safe to merge. The single parse path, comprehensive deny_unknown_fields coverage, 130+ corpus round-trip tests, and 9 strictness regressions give strong assurance that the closed schema is correct and backward-compatible.

The core logic changes — schema-backed parsing, uniqueness validation, idempotent synthesis — are all correct and thoroughly tested. The only non-trivial concern is in the migration differential test helper, where a replacen call silently no-ops if the hardcoded duplicate-key pattern doesn't match the historical git blob, producing a confusing test failure rather than a clear fixture-mismatch signal. This is a test-reliability nit and not a production correctness issue.

migration_differential.rs — the replacen arm in repair_strict_historical_fixture for evolution_run.ioa.toml should assert that a replacement actually occurred, matching the expect-based approach used for the temper_agent.ioa.toml arm.

Important Files Changed

Filename Overview
crates/temper-spec/src/automaton/toml_parser/mod.rs Drops ~750 LOC hand-rolled state machine in favour of a single toml::from_str call; the entire hand-rolled line parser, multi-pass section extractors, and extract_webhooks/extract_action_triggers helpers are removed. Result is correct and dramatically simpler.
crates/temper-spec/src/automaton/parser.rs Adds validate_unique_names for every named entity type and replaces automaton.integrations.extend(synthesized) with an idempotent loop that correctly handles the (0,0)→push / (1,1)→skip / conflict→error trichotomy for round-trip stability. Validation runs before expansion, which is the right order.
crates/temper-spec/src/automaton/types.rs Adds #[serde(deny_unknown_fields)] to every public schema type; removes Deserialize derive from Effect/Integration/ActionParam in favour of custom impls in deserialization.rs; switches Integration.config from #[serde(flatten)] to an explicit BTreeMap field (changes canonical serialisation format from flat keys to config = { … }).
crates/temper-spec/src/automaton/toml_parser/compatibility.rs New file providing backward-compatible deserializers for legacy string-form guards, string-wrapped effect arrays, boolish strings, comma-separated copy_fields, and array-or-single cedar_gate. Guard operator precedence (>= before >) and the inner-document re-parse for string-array effects are both handled correctly.
crates/temper-spec/src/automaton/types/deserialization.rs Custom Deserialize impls for ActionParam, Effect (via EffectDefinition with deny_unknown_fields), and Integration (collects unknown fields as config, detects config-key duplicates). SetCounterFromParam default-param handling and integration_config_value coercion look correct.
crates/temper-spec/src/automaton/parser_strictness_test.rs Nine fail-closed regressions covering typo tables, field typos, truncated values, unnamed blocks, missing webhook action, trailing garbage in string-wrapped effect arrays, duplicate invariant names, duplicate TOML keys, and a round-trip through context_entity. must_reject_with correctly gates on line/column for TOML-sourced errors and a content fragment.
crates/temper-spec/tests/ioa_corpus.rs Corpus test that parses 130+ repo specs, serialises each, reparses, and verifies serialisation stability. Missing roots are now silently skipped. entry.expect() inside collect_ioa_specs can still panic for unreadable directory entries, though that scenario is far less likely than a missing root directory.
crates/temper-spec/tests/migration_differential.rs Adds repair_strict_historical_fixture to strip duplicates the hand-rolled parser silently swallowed before passing historical blobs to the strict parser. The temper_agent.ioa.toml arm uses expect (fail-fast), but the evolution_run.ioa.toml arm uses replacen which silently no-ops when the pattern doesn't match.
crates/temper-spec/src/bin/verify_specs.rs Adds --syntax-only flag wired to LivenessEnforcement::WarnOnly; correctly threads liveness_mode through walk/check_spec helper chain. The pre-commit hook now calls this binary directly instead of temper-cli, removing the indirect coupling.
crates/temper-spec/src/automaton/field_invariant.rs Adds #[serde(deny_unknown_fields)] to FieldInvariant; one-line change, correct.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["`**IOA TOML source**`"] --> B["toml::from_str\n(single parse pass)"]
    B -->|TOML error| E1["AutomatonParseError::Toml\n(with line/column span)"]
    B -->|Ok| C["validate(automaton)\n- uniqueness checks for all named entities\n- state/guard/liveness semantic checks"]
    C -->|validation error| E2["AutomatonParseError::Validation"]
    C -->|Ok| D["wire_state_timeout_from_states"]
    D --> F["expand_external_action_triggers\n- (0,0) → push synthesized integration\n- (1,1) identical → skip (idempotent)\n- else → conflict error"]
    F -->|conflict| E3["AutomatonParseError::Validation"]
    F -->|Ok| G["enforce_liveness (WarnOnly / Enforce)"]
    G --> H["✅ Automaton"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["`**IOA TOML source**`"] --> B["toml::from_str\n(single parse pass)"]
    B -->|TOML error| E1["AutomatonParseError::Toml\n(with line/column span)"]
    B -->|Ok| C["validate(automaton)\n- uniqueness checks for all named entities\n- state/guard/liveness semantic checks"]
    C -->|validation error| E2["AutomatonParseError::Validation"]
    C -->|Ok| D["wire_state_timeout_from_states"]
    D --> F["expand_external_action_triggers\n- (0,0) → push synthesized integration\n- (1,1) identical → skip (idempotent)\n- else → conflict error"]
    F -->|conflict| E3["AutomatonParseError::Validation"]
    F -->|Ok| G["enforce_liveness (WarnOnly / Enforce)"]
    G --> H["✅ Automaton"]
Loading

Reviews (2): Last reviewed commit: "test(spec): skip missing IOA corpus root..." | Re-trigger Greptile

rita-aga added 2 commits July 14, 2026 09:44
Add fail-closed regressions for unknown tables/fields, truncated safety
blocks, unnamed core declarations, malformed webhooks, duplicate keys, and
context_entity round-trip. Record ADR-0179 as the intended remediation.
Parse each IOA document once into the canonical Automaton schema with
deny_unknown_fields (ADR-0179). Drop the hand-rolled line scanner and
silent section extractors so typos, truncated safety blocks, unnamed
declarations, and malformed webhooks fail closed with source spans.

- Compatibility deserializers keep supported string guards/effects
- Uniqueness validation for safety-relevant declaration names
- Idempotent external-trigger integration synthesis on round-trip
- Fix in-tree fixtures that relied on silent drops
- Corpus + migration differential coverage
@rita-aga

Copy link
Copy Markdown
Collaborator Author

Independent Grok code review (ARN-214 / ADR-0179)

Summary

Solid fail-closed parser rewrite: one full-document toml::from_str into the canonical Automaton schema, deny_unknown_fields on closed records, uniqueness validation, field-local legacy guard/effect compatibility, and corpus/round-trip coverage. This closes the silent-drop class (unknown tables/fields, truncated asserts, unnamed blocks, webhook extractors that returned empty lists). Fixture migrations ([[state_timeout]], structured effects, duplicate state removal) match the stricter boundary.

What was done well

  • Single AST path replaces dual hand-parser + section extractors; large deletion is justified.
  • Integration stays the intentional open config surface via custom deserialize (not deny_unknown_fields).
  • Strictness suite (9 cases) asserts fail-closed and source line/column spans.
  • ioa_corpus (≥130 specs) + migration differential repairs only historical silent overwrites.
  • verify_specs --syntax-only + pre-commit wiring is the right hook surface.
  • Idempotent trigger-integration synthesis on round-trip is careful and tested.

Findings

Important

  1. Integration serialization shape change (types.rs): config moved from #[serde(flatten)] to a nested map with skip_serializing_if = "BTreeMap::is_empty". Deserialize still accepts flat legacy keys (good), but canonical serialize now nests under config (or omits). Tooling/diff consumers that assumed flat integration keys will see a schema shape change. ADR acknowledges this; ensure any external publishers/docs that round-trip integrations are aware. Corpus reparse-after-serialize mitigates parser breakage, not downstream flatten assumptions.

  2. Legacy effect structured aliases are narrower than the ADR wording (types/deserialization.rs EffectDefinition): aliases exist for emit_event / spawn_entity, but not for older PascalCase test forms (e.g. historical IncrementCounter in server tests — fixed in-tree). ADR says “effect type aliases … remain accepted”; if any out-of-repo specs still use undocumented aliases the old line parser silently ignored, they will now hard-fail (correct for safety, but a deploy cliff). Residual risk only; no in-tree hole after fixture fixes.

Suggestions

  1. Uniqueness of state_timeout state keys is not validated the way action/invariant names are. Two [[state_timeout]] rows for the same state would parse; consider rejecting duplicates if runtime arms one timer per state.
  2. ActionParam / Effect custom Deserialize without derive is fine; keep a short module comment that Serialize stays derive-only so tag renames stay in sync with EffectDefinition.
  3. Pre-commit still redirects stderr to /dev/null on syntax check — operators only see “BLOCKED”; consider surfacing the parse span once (out of scope for this PR).

Plan alignment

Matches ADR-0179 readiness gates: corpus parse/round-trip, malformed source fails before validation, declarations retained, no permissive mode. Competitor-independent ADR number is correct.

Residual risks

  • First strict deploy of external/customer IOA that relied on silent drops will fail closed (intentional).
  • Historical migration fixtures require the surgical repair_strict_historical_fixture — acceptable and documented.

No Critical correctness defects found in the HEAD diff.

Verdict: PASS

@rita-aga

Copy link
Copy Markdown
Collaborator Author

@greptile review

@rita-aga

Copy link
Copy Markdown
Collaborator Author

ARENA SHIPPABLE · Grok · 2026-07-14 11:57 PDT

PR: #392
HEAD: 89ea51256c7b · branch grok/arn-214-malformed-safety
Linear: ARN-214 · ADR: 0179
Merge: nothing (arena rules)

Checklist

Gate Evidence
RED→GREEN on branch history
Independent same-model review Verdict: PASS posted on PR
Greptile requested after PASS
Local tests targeted suite green before push
CI GitHub Actions on head

Summary

reject malformed IOA safety

Comment thread crates/temper-spec/tests/ioa_corpus.rs
Comment on lines +84 to +102
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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Effect-type whitelist is a latent maintenance trap

deserialize_effect_entry checks a hardcoded list of effect type strings before delegating to Effect::deserialize, which already delegates to EffectDefinition with #[serde(tag = "type", deny_unknown_fields)]. Any effect type added to the enum in the future will parse correctly without touching this whitelist, but an unknown type will produce two different error messages depending on whether it hits the guard or serde — making the whitelist misleading rather than protective. The whitelist can be removed entirely; EffectDefinition provides the real enforcement.

Suggested change
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())
Effect::deserialize(value).map_err(|error| error.to_string())
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-spec/src/automaton/toml_parser/compatibility.rs
Line: 84-102

Comment:
**Effect-type whitelist is a latent maintenance trap**

`deserialize_effect_entry` checks a hardcoded list of effect type strings before delegating to `Effect::deserialize`, which already delegates to `EffectDefinition` with `#[serde(tag = "type", deny_unknown_fields)]`. Any effect type added to the enum in the future will parse correctly without touching this whitelist, but an unknown type will produce two different error messages depending on whether it hits the guard or serde — making the whitelist misleading rather than protective. The whitelist can be removed entirely; `EffectDefinition` provides the real enforcement.

```suggestion
            Effect::deserialize(value).map_err(|error| error.to_string())
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex Fix in Cursor

Comment on lines 28 to +33
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let mut args: Vec<String> = 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 --syntax-only still runs full semantic validation

The flag sets LivenessEnforcement::WarnOnly, but parse_automaton_with_liveness still validates state references, guard correctness, trigger field presence, cross-entity declarations, and all other semantic invariants in validate(). Pre-commit error messages say "Spec syntax error in $SPEC", which will mislead authors when the actual rejection is a semantic violation (e.g., from referencing an undeclared state). Consider renaming to --no-liveness or updating the pre-commit error message to "Spec validation error".

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-spec/src/bin/verify_specs.rs
Line: 28-33

Comment:
**`--syntax-only` still runs full semantic validation**

The flag sets `LivenessEnforcement::WarnOnly`, but `parse_automaton_with_liveness` still validates state references, guard correctness, trigger field presence, cross-entity declarations, and all other semantic invariants in `validate()`. Pre-commit error messages say "Spec syntax error in $SPEC", which will mislead authors when the actual rejection is a semantic violation (e.g., `from` referencing an undeclared state). Consider renaming to `--no-liveness` or updating the pre-commit error message to "Spec validation error".

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex Fix in Cursor

@rita-aga

Copy link
Copy Markdown
Collaborator Author

@greptile review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant