diff --git a/docs/proposals/P-022-rust-core-migration.md b/docs/proposals/P-022-rust-core-migration.md index cfec9b95..86792879 100644 --- a/docs/proposals/P-022-rust-core-migration.md +++ b/docs/proposals/P-022-rust-core-migration.md @@ -53,7 +53,7 @@ was #258 alone, which is satisfied. Per the checkpoints #259 itself defines: | #259 checkpoint | Status | Evidence / what remains | |---|---|---| -| 1 — typed OwnIR validation | **partial** | `OwnIr::from_json` + the #294 OD-2 fail-loud unknown-kind rule. Full validation acceptance/rejection parity (fixture layer 1) is out of the current slice | +| 1 — typed OwnIR validation | **acceptance surface closed except two named families — not yet complete** | Two censuses. The first froze 77 controls, closed twelve permissive documents and read 0/0/0 — then review found seven divergences the ledger could not express, because the same author wrote the ledger and the port and one gap in reading BR-D1 produced a matching gap in each (`_svc()` always supplied `lifetime`, so no control could omit it). The re-census is derived from `load()` and `obligations.py` line by line: **193 controls**, which opened a further **58** permissive documents and **9** category mismatches. Closing them was architectural — the strict door is now a sequential validator over the raw document (`own-ir/src/strict.rs`) reproducing BR-D1's interleaving of shape and semantics *per section, in declaration order*; `serde` is the typed constructor, and a document it rejects after validation is reported as a hole in the validator and asserted against. The obligation **acceptance grammar** is ported (`own-ir/src/protocol.rs`); protocol *analysis* is not, and is not part of what the door accepts. Taxonomy is now **seven** categories: `WellFormedness` was added for the two protocol rules whose values are all correctly typed and whose records still cannot mean anything — a category set frozen by the first census is a claim about that census, not about the contract. Matrix 31/162, 0/0/0; 31 mutations each caught, five only by the validator-hole guard and two changing nothing but a category. **Why this is not yet complete:** two Python-accept/Rust-reject families are measured and deliberately excluded from the ledger — source coordinates beyond Rust's integer range, and sufficiently deep protocol/flow nesting. 0/0/0 therefore means "over a set from which two known divergence families were removed", which is not the parity #259 asks for. Both close in one **Python-first** defensive-limit change (signed-64 coordinates; one measured domain nesting limit, at-limit accept and limit+1 reject, written into the OwnIR contract). That lands first; this checkpoint is then rebased, gains boundary controls for both families, and is re-measured before it may be called complete. #294 OD-2 remains a separate tolerant-door concern | | 2 — fact lowering | **complete** | `lower()` → `own_lowered`; **27/27** `rust_replay` cases in `tests/fixtures/lowered/manifest.json` byte-exact | | 3 — interprocedural MOS | **complete for the stage-1 domain** | `dump_summaries()` byte-identical to `python -m ownlang summaries` across **35** `*.summaries.json` goldens. Container-valued metadata is **outside** the declared scalar-metadata parity domain — a separate #294-class door decision, not a silent gap | | 4 — analysis wiring | **not started** | the crate states its own boundary: "no diagnostics, no analysis" | @@ -72,7 +72,13 @@ was #258 alone, which is satisfied. Per the checkpoints #259 itself defines: | 7b | Rust `own-cli`: command/output/exit-code parity | #261 | blocked — needs the production bridge and the output surfaces | | 8 | Rust-default **cutover**, rollback gate, Python distribution removal | #262 | blocked by #260/#261 and final parity | -**Preferred queue:** #259 remaining (cp1 → cp4 → cp5) → #260/#269. +**Preferred queue:** Python-first defensive limits → finish cp1 → cp4 → cp5, +then #260/#269. The limits change is **not** a side quest: it closes the two measured +Python-accept/Rust-reject families (source-coordinate integers beyond signed 64 +bits, and nesting depth), and until it lands cp1's 0/0/0 is a result over a set +with two known divergence families removed from it. Closing them by widening +Rust — arbitrary-precision integers, `unbounded_depth` — is refused: the limit +belongs in the contract, not in the representation. ### What #256 asked for that the tree does not have diff --git a/docs/proposals/README.md b/docs/proposals/README.md index 3eb028c8..648016cc 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -41,7 +41,7 @@ proposal is marked `done` with a pointer. | [P-017](P-017-multi-stack-frontends.md) | Multi-stack frontends (OwnTS / OwnJVM: OwnJava + OwnKotlin) | draft | | [P-020](P-020-ownts-react-effects.md) | OwnTS React effects profile (`Own.React`) — the effect-storm angle | draft | | [P-021](P-021-async-audit-pack.md) | Async audit pack (`Own.Async`) | draft | -| [P-022](P-022-rust-core-migration.md) | Rust core migration: crate DAG, patterns, prior art, differential oracle (Python = golden) | in execution — steps 0–4 built (#214/#249); step 5a done (full diagnostic contract, #255 via #319/#320/#321); step 5b SARIF done (#256; `.ownreport.json` struck — a buffer report needing the AST, not a diagnostics surface); step 6a done (`spec/Bridge.md`, #258); step 6b underway (`own-lowered`/`own-bridge`, #259: lowering + MOS parity landed, analysis wiring open); Python authoritative until cutover | +| [P-022](P-022-rust-core-migration.md) | Rust core migration: crate DAG, patterns, prior art, differential oracle (Python = golden) | in execution — steps 0–4 built (#214/#249); step 5a done (full diagnostic contract, #255 via #319/#320/#321); step 5b SARIF done (#256; `.ownreport.json` struck — a buffer report needing the AST, not a diagnostics surface); step 6a done (`spec/Bridge.md`, #258); step 6b underway (`own-lowered`/`own-bridge`, #259: lowering and MOS parity landed; strict-door validation re-censused at 193 controls after a first 0/0/0 proved to be the ledger agreeing with its own author, and awaiting a Python-first defensive-limit change before it can be called complete; analysis wiring open); Python authoritative until cutover | | [P-023](P-023-architecture-guard.md) | Architecture guard (`Own.Arch`): rules.yaml intent model + dependency-graph gate + baseline ratchet | draft | | [P-024](P-024-security-audit-profile.md) | Security audit profile (external tools + SARIF adapters; rejects own scanner engine) | draft | | [P-025](P-025-obligation-protocols.md) | Obligation protocols (`Own.Protocols`): barrier-sensitive project invariants (OBL001–005) | first slice built (core + bridge + fixtures; extractor pending) | diff --git a/rust/crates/own-bridge/tests/replay.rs b/rust/crates/own-bridge/tests/replay.rs index e3dc7fa8..8c3080c9 100644 --- a/rust/crates/own-bridge/tests/replay.rs +++ b/rust/crates/own-bridge/tests/replay.rs @@ -41,9 +41,20 @@ fn read(name: &str) -> String { } /// facts text → the canonical Layer 2 bytes, through the Rust pipeline only. +/// +/// Deserialized **without** `OwnIr::from_json`, on purpose. That is the strict +/// door (BR-D1), and this harness exercises the **tolerant** one — the path +/// `check_facts`/`to_module` take when an embedder hands over a dict directly, +/// never having called `load()`. Routing through the strict door would make +/// this suite test the wrong entry surface, and since #259 cp1 taught that door +/// to enforce IR4 it would also make `tolerant_unknown_kind` unreachable: the +/// strict door would reject the facts before the lowerer could demonstrate that +/// it rejects them too. The lowerer's own fail-loud check (#294 OD-2) exists +/// precisely because this path bypasses `load()`, so the test must bypass it as +/// well or it proves nothing. fn lower_bytes(facts_text: &str, case: &str) -> String { - let facts = own_ir::OwnIr::from_json(facts_text) - .unwrap_or_else(|e| panic!("{case}: own-ir rejected the shared facts: {e}")); + let facts: own_ir::OwnIr = serde_json::from_str(facts_text) + .unwrap_or_else(|e| panic!("{case}: shared facts do not deserialize: {e}")); let surface = match own_bridge::lower(&facts) { Ok(doc) => Surface::Lowered(doc), Err(e) => Surface::Rejected(Rejected { diff --git a/rust/crates/own-ir/src/lib.rs b/rust/crates/own-ir/src/lib.rs index c97f3526..1bc46913 100644 --- a/rust/crates/own-ir/src/lib.rs +++ b/rust/crates/own-ir/src/lib.rs @@ -3,12 +3,35 @@ //! `OwnIR` is the frozen seam between the frontends (the Roslyn C# extractor, //! `OwnTS`) and the core: a versioned JSON fact vocabulary. This crate is the //! Rust side of that seam. Its acceptance rule mirrors the Python reference -//! (`ownlang/ownir.py::load`) exactly: +//! (`ownlang/ownir.py::load`) — a claim that is **measured**, not asserted: +//! `tests/validation_replay.rs` replays a 193-control Python-authored ledger +//! and requires zero Rust-only accepts, zero Rust-only rejects and zero +//! error-category mismatches. //! -//! * **typed fields are only the ones Python validates** — everything else -//! rides in a flattened `extra` map, so additive optional fields a newer -//! frontend emits are tolerated *and preserved on round-trip* (the parity -//! property `tests/roundtrip.rs` pins against the repo's `OwnIR` fixtures); +//! The measurement had to be taken twice, and the second time is the one worth +//! reading. A first sweep of 77 controls found twelve permissive documents, +//! fixed them, and read 0/0/0 — but the same author had written the ledger and +//! the port, so a gap in reading BR-D1 produced a matching gap in each. A +//! re-census built from the reference line by line opened **58** more +//! permissive documents and **9** category mismatches. Both numbers are in the +//! commit history on purpose: a differential oracle written by the author of +//! the implementation measures the author's understanding until something +//! external disagrees with it. +//! +//! * **the strict door is the `strict` module, not serde.** Validation runs over +//! the raw document and is complete before deserialization begins, because +//! BR-D1 interleaves shape and semantic checks per section in +//! document-declaration order — an order neither serde's field traversal nor +//! a "semantics first, shapes second" gate reproduces. serde afterwards is a +//! *constructor*; if it still rejects, that is a hole in the validator, and +//! it says so ([`VALIDATOR_HOLE`]); +//! * **typed fields are not what makes a rule enforced** — everything +//! undeclared rides in a flattened `extra` map, so additive optional fields a +//! newer frontend emits are tolerated *and preserved on round-trip* +//! (`tests/roundtrip.rs`). Six fields Python validated were once absent from +//! this model and fell into `extra`, escaping checking entirely; that class +//! of bug is now caught by the validator rather than prevented by remembering +//! to declare things; //! * the **schema version gates first** (`ownir_version`, absent ⇒ v0), and a //! vocabulary mismatch fails loudly with an actionable message; //! * JSON `true` is **not** an integer here (unlike Python, where `bool` is an @@ -20,7 +43,9 @@ //! Error *message* parity with Python is not claimed yet — that lands with the //! shared error-text fixtures (P-022 oracle section), not by copy-paste. +mod protocol; pub mod span; +mod strict; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -29,19 +54,123 @@ use serde_json::{Map, Value}; /// vocabulary change — additive optional fields are NOT a version bump. pub const OWNIR_VERSION: i64 = 0; -/// A shape/vocabulary violation in an `OwnIR` document. Facts are external -/// input, so a malformed file must fail with a clear error, not a panic. +/// Why a document was rejected. +/// +/// This is the cross-language comparison surface. The reference funnels every rejection into one `OwnIRError` whose message is +/// a human-facing presentation aid. #259 asks for a matching error +/// *class/category*, so parity is pinned on this enum instead: byte-comparing +/// two languages' English would freeze a debug surface as a contract. +/// +/// One variant per **mechanism** a loader can reject on, not one per message. +/// The set is closed by measurement, in both directions: +/// +/// * it cannot outgrow its evidence — `tests/fixtures/ownir_validation.json` +/// fails if a declared category has no control exercising it. There is +/// deliberately no `Reference` variant, because the strict-door sweep found +/// no load-time referential constraint and adding one on the strength of an +/// issue's prose would invent a category nothing can reach; +/// * and it is not frozen against new evidence. [`Self::WellFormedness`] was +/// added when the second census found a mechanism the first had not reached. +/// A taxonomy settled by one census is a claim about that census, not about +/// the contract, and reporting the new mechanism under the nearest existing +/// variant would be exactly the substitution this enum exists to prevent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum OwnIrErrorKind { + /// The document is not JSON at all. + Json, + /// The `ownir_version` gate — wrong type, or a version this core cannot read. + Version, + /// Right place, wrong JSON type or container shape. + Shape, + /// Right JSON type, value outside a closed set (resource kind, lifetime, + /// parameter effect). + Vocabulary, + /// An identity field that is empty, non-string, or duplicated. + Identity, + /// A source coordinate violating the 1-based contract (#317). + Location, + /// Every value has the right type and the right vocabulary, and the record + /// still cannot mean anything — a rule that structurally never fires, a + /// barrier the walk can never reach. + /// + /// This variant exists because the second census found the mechanism, and + /// a taxonomy frozen by the *first* census is not evidence about the + /// second. Reporting these as `Shape` was the exact failure the taxonomy + /// was built to prevent: letting the nearest available category stand in + /// for the real one. + WellFormedness, +} + +impl OwnIrErrorKind { + /// The stable wire name the parity ledger compares on. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Json => "json", + Self::Version => "version", + Self::Shape => "shape", + Self::Vocabulary => "vocabulary", + Self::Identity => "identity", + Self::Location => "location", + Self::WellFormedness => "well_formedness", + } + } +} + +/// A rejection from the strict door. Facts are external input, so a malformed +/// file must fail with a clear error, not a panic. +/// +/// `kind` is the contract; `message` stays actionable for a human but is never +/// compared across languages. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct OwnIrError(pub String); +pub struct OwnIrError { + pub kind: OwnIrErrorKind, + pub message: String, +} + +impl OwnIrError { + fn new(kind: OwnIrErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} impl std::fmt::Display for OwnIrError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) + f.write_str(&self.message) } } impl std::error::Error for OwnIrError {} +/// Sentinel prefix on the one error [`OwnIr::from_json`] can raise that is not +/// a rejection. +/// +/// Once the `strict` module has accepted a document, `serde` is only building the +/// typed value; if it still refuses, the validator is missing a rule the model +/// happens to encode. That is a defect in this crate, not in the document, and +/// it must not hide inside a plausible-looking `Shape` error — so it is marked, +/// and `validation_replay` asserts no control in the ledger reaches it. +pub const VALIDATOR_HOLE: &str = "strict validator hole"; + +/// The closed set of resource discriminators (IR4). +/// +/// A present-but-unknown kind changes routing, so the strict door rejects it +/// rather than let it fall through to the owned/subscription path; a new kind +/// is a vocabulary change that must bump [`OWNIR_VERSION`]. +pub const KNOWN_RESOURCE_KINDS: [&str; 8] = [ + "capture", + "disposable", + "local-disposable", + "pool", + "subscribe", + "subscription", + "timer", + "unresolved-subscription", +]; + /// Deserializer for load()-validated optional fields: **absent** means default /// (Python's `d.get("f", default)`), but a **present `null` is rejected** — /// exactly like Python's `isinstance` check failing on `None`. `serde(default)` @@ -138,6 +267,37 @@ pub struct Subscription { skip_serializing_if = "Option::is_none" )] pub source_type: Option>, + /// Interprocedural publisher provenance (P-004, #146). Nullable-optional: + /// the reference tests `is not None and not isinstance(str)`, so an + /// explicit `null` is accepted and read as absent. + #[serde( + default, + deserialize_with = "nullable", + skip_serializing_if = "Option::is_none" + )] + pub source_provenance: Option>, + /// The mandatory justification on an inline suppression (#209). Same + /// nullable-optional rule as `source_provenance` — and deliberately *not* + /// the same as `resource`, which rejects `null`. These fields shared one + /// bug (absent from the Rust model, so serde never checked them); they do + /// not share one policy. + #[serde( + default, + deserialize_with = "nullable", + skip_serializing_if = "Option::is_none" + )] + pub ignore_reason: Option>, + /// The source **column** of the node `line` anchors on (#317). + /// + /// Held as a raw [`Value`] on purpose. A typed `Option` would + /// make `0`, `-1`, `true` and `"3"` all die inside serde — correct + /// rejections, but every one of them reported as [`OwnIrErrorKind::Shape`], + /// when the contract being violated is the 1-based coordinate rule. The + /// implementation mechanism must not pick the semantic category, so the + /// check lives in the `strict` module — reached from [`OwnIr::from_json`] + /// and [`OwnIr::validate`] alike — where it can answer `Location`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub column: Option, #[serde(flatten)] pub extra: Map, } @@ -316,6 +476,16 @@ pub struct Param { /// untyped here — their vocabulary is the bridge's concern, not the schema's. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct Function { + /// The overload key MOS resolution joins on. Nullable-optional: the + /// reference rejects a non-string but reads `null` as absent. A malformed + /// `sig` on a FLOW OP is treated differently (read as absent, degrading to + /// the merged summary) — that is a different door, not this one. + #[serde( + default, + deserialize_with = "nullable", + skip_serializing_if = "Option::is_none" + )] + pub sig: Option>, #[serde( default, deserialize_with = "reject_null", @@ -359,68 +529,176 @@ pub struct OwnIr { skip_serializing_if = "Option::is_none" )] pub functions: Option>, + /// Obligation-protocol declarations. Held as raw values because nothing + /// consumes a typed representation yet — not because the records go + /// unchecked. + /// + /// The reference checks only list-ness *here* and delegates each record to + /// the shared obligation parser. That parser is called by `load()` and its + /// errors are wrapped as `OwnIRError`, so it is part of the strict-door + /// contract, and its **acceptance grammar** is ported in `protocol.rs`. An + /// earlier revision of this comment called record validity a delegated + /// boundary "not yet mirrored"; the second census measured that as a hole + /// in the door worth 47 of its 58 permissive documents. Protocol + /// *analysis* — the lattice, the walker, verdicts — is still absent, and + /// that one is a real boundary. + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub protocols: Option>, + /// Per-method protocol facts. Same arrangement as `protocols`: list shape + /// here, the event-tree grammar in `protocol.rs`, no analysis anywhere. + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub protocol_functions: Option>, #[serde(flatten)] pub extra: Map, } impl OwnIr { - /// Parse + shape-check an `OwnIR` JSON document. Mirrors the acceptance of - /// Python `ownlang.ownir.load` (version gate first, then field shapes). + /// Parse + shape-check an `OwnIR` JSON document — the **strict door** + /// (BR-D1). Accepts the same language as Python `ownlang.ownir.load`, + /// including the observable order in which it rejects. + /// + /// Validation runs over the **raw** document (the `strict` module) and is + /// finished before `serde` sees anything. That is not an optimisation of + /// the previous design, it is the only arrangement that can reproduce the + /// contract: BR-D1 interleaves shape and semantic checks per section, in + /// document-declaration order, and neither serde's field order nor a + /// "semantics first, shapes second" two-pass gate is that order. + /// + /// The tolerant door (`check_facts`/`to_module`, which take a document + /// directly and never call `load`) is a *different* entry surface with its + /// own fail-loud rules in `own-bridge`. Neither subsumes the other: the + /// lowerer's unknown-resource check (#294 OD-2) guards callers who bypass + /// this function entirely. /// /// # Errors /// [`OwnIrError`] on invalid JSON, a schema-version mismatch, or any field /// that the reference implementation would reject. pub fn from_json(text: &str) -> Result { - let doc: Self = serde_json::from_str(text) - .map_err(|e| OwnIrError(format!("OwnIR facts are not valid: {e}")))?; - doc.validate()?; - Ok(doc) + let raw: Value = serde_json::from_str(text) + .map_err(|e| OwnIrError::new(OwnIrErrorKind::Json, format!("not valid JSON: {e}")))?; + let Some(obj) = raw.as_object() else { + return Err(OwnIrError::new( + OwnIrErrorKind::Shape, + "OwnIR root must be a JSON object", + )); + }; + strict::validate_document(obj)?; + // serde is the CONSTRUCTOR, not the arbiter: the document has already + // been accepted, so a failure here is a hole in the validator rather + // than a rejection. Marked with a sentinel the replay test asserts no + // control ever reaches — see `no_control_escapes_into_serde`. + serde_json::from_value(raw).map_err(|e| { + OwnIrError::new( + OwnIrErrorKind::Shape, + format!( + "{VALIDATOR_HOLE}: the strict validator accepted a document serde \ + then refused, so a rule is missing from it — {e}" + ), + ) + }) } - /// The checks serde's typing cannot express: the version gate and the - /// non-empty-identity rules. + /// The strict door applied to a value built in memory rather than parsed. + /// + /// Serializing and re-validating costs a round-trip, and buys the property + /// that broke this crate once already: there is exactly **one** copy of the + /// acceptance law. The previous design kept a second, smaller copy here, and + /// a mutation planted in one was caught by the other — a false "survived" + /// that cost real time to explain. /// /// # Errors - /// [`OwnIrError`] on a schema-version mismatch or an empty identity field. + /// [`OwnIrError`] if the value would not survive [`OwnIr::from_json`]. pub fn validate(&self) -> Result<(), OwnIrError> { - let ver = self.ownir_version.unwrap_or(OWNIR_VERSION); - if ver != OWNIR_VERSION { - return Err(OwnIrError(format!( - "OwnIR facts are schema v{ver}, but this core understands \ - v{OWNIR_VERSION}. Build the extractor and the core from the \ - same commit — the OwnIR fact vocabulary changed between the \ - version that produced this file and the one reading it." - ))); - } - for s in self.services.iter().flatten() { - if s.name.is_empty() { - return Err(OwnIrError( - "service 'name' must be a non-empty string".to_owned(), - )); - } - } - for p in self - .functions - .iter() - .flatten() - .flat_map(|f| f.params.iter().flatten()) - { - if p.name.is_empty() { - return Err(OwnIrError( - "parameter 'name' must be a non-empty string".to_owned(), - )); - } - } - Ok(()) + let value = self.to_value()?; + let Some(obj) = value.as_object() else { + return Err(OwnIrError::new( + OwnIrErrorKind::Shape, + "OwnIR root must be a JSON object", + )); + }; + strict::validate_document(obj) } /// Serialize back to a JSON value. Together with `from_json` this is the /// round-trip the oracle's first parity check rides on. /// + /// Refuses a document whose raw values nest deeper than + /// `strict::MAX_VALUE_DEPTH` (128). `serde_json::to_value` recurses over a + /// `Value`, and on a deep enough one it does not fail — it **aborts the + /// process**. A stack overflow cannot be caught, so the only place to stop + /// it is before serialization starts. + /// + /// **128 is the contract**, and the only depth number that is. It is + /// `serde_json`'s own parser bound, so nothing [`OwnIr::from_json`] can + /// accept is refused here. The depth at which an unguarded serialization + /// would actually abort is a property of one stack size, build profile and + /// platform; useful forensics, not a specification, so it is not written as + /// one. + /// /// # Errors - /// [`OwnIrError`] if serialization fails (it cannot for these types, but - /// the contract stays honest rather than panicking). + /// [`OwnIrError`] if a raw value is nested too deeply, or if serialization + /// fails (it cannot for these types, but the contract stays honest rather + /// than panicking). pub fn to_value(&self) -> Result { - serde_json::to_value(self).map_err(|e| OwnIrError(format!("serialize failed: {e}"))) + self.check_raw_depth()?; + serde_json::to_value(self) + .map_err(|e| OwnIrError::new(OwnIrErrorKind::Shape, format!("serialize failed: {e}"))) + } + + /// Depth-check every raw [`Value`] the model carries. + /// + /// The *typed* nesting is fixed — root → components → subscriptions and so + /// on — so these are plain loops, not recursion. Only the `extra` maps, the + /// two protocol sections and `Subscription::column` hold values of + /// caller-chosen shape, and each is measured iteratively. + fn check_raw_depth(&self) -> Result<(), OwnIrError> { + strict::check_map_depth(&self.extra, "OwnIR root")?; + for value in self.protocols.iter().flatten() { + strict::check_depth(value, "protocol")?; + } + for value in self.protocol_functions.iter().flatten() { + strict::check_depth(value, "protocol function")?; + } + for component in self.components.iter().flatten() { + strict::check_map_depth(&component.extra, "component")?; + for sub in component.subscriptions.iter().flatten() { + strict::check_map_depth(&sub.extra, "subscription")?; + if let Some(column) = sub.column.as_ref() { + strict::check_depth(column, "subscription 'column'")?; + } + } + } + for service in self.services.iter().flatten() { + strict::check_map_depth(&service.extra, "service")?; + for site in service + .root_resolve_sites + .iter() + .flatten() + .chain(service.scope_cache_sites.iter().flatten()) + { + strict::check_map_depth(&site.extra, "service call site")?; + } + } + for effect in self.effects.iter().flatten() { + strict::check_map_depth(&effect.extra, "effect")?; + for binding in effect.bindings.iter().flatten() { + strict::check_map_depth(&binding.extra, "binding")?; + } + } + for function in self.functions.iter().flatten() { + strict::check_map_depth(&function.extra, "function")?; + for param in function.params.iter().flatten() { + strict::check_map_depth(¶m.extra, "parameter")?; + } + } + Ok(()) } } diff --git a/rust/crates/own-ir/src/protocol.rs b/rust/crates/own-ir/src/protocol.rs new file mode 100644 index 00000000..7e2710c3 --- /dev/null +++ b/rust/crates/own-ir/src/protocol.rs @@ -0,0 +1,348 @@ +//! The obligation-protocol **acceptance grammar** (P-025), ported from the +//! shared parser in `ownlang/obligations.py`. +//! +//! # Why this lives in `own-ir` at all +//! +//! `load()` delegates every `protocols[]` and `protocol_functions[]` record to +//! that parser, and wraps its errors as `OwnIRError`. So the parser is part of +//! the strict-door contract even though it lives in another module: a document +//! the parser refuses is a document `load()` refuses. Leaving it out would not +//! be a smaller checkpoint, it would be a strict door with a hole in it — which +//! is what the second census measured, at 47 of 58 permissive cases. +//! +//! # What is ported, and what deliberately is not +//! +//! Ported: **acceptance only** — `parse_protocol`, `parse_matcher`, +//! `parse_events`, `parse_method`. These answer "is this a well-formed protocol +//! declaration / event tree?". +//! +//! Not ported: everything that *uses* the answer — the obligation lattice, the +//! walker, matching, `check_protocols`, verdicts. Those are analysis, they are +//! not part of what the door accepts, and they belong to a later checkpoint. +//! +//! Consequently these functions build no `Protocol` value for a caller to use. +//! They validate and return the record's identity, and `own-ir` keeps +//! `protocols` / `protocol_functions` as raw [`Value`]s. Parsing them into typed +//! Rust structures now would be inventing a representation before anything +//! consumes it. +//! +//! # Two rules that are not about types +//! +//! `parse_protocol` refuses a protocol that can never fire (no barriers with +//! `exit_barriers: false`) and one whose barrier equals its `opens` matcher (the +//! walk checks opens first, so the barrier is dead). Every value in such a +//! record has the right type and a legal vocabulary; what is wrong is that the +//! record cannot *mean* anything. +//! +//! They are [`OwnIrErrorKind::WellFormedness`]. An earlier revision reported +//! them as `Shape` on the grounds that the taxonomy was already frozen at six — +//! which got the reasoning backwards. The taxonomy was frozen by the *first* +//! census; this mechanism was found by the *second*. Freezing a category set +//! against later evidence, and then filing new mechanisms under the nearest +//! available name, is the precise substitution the enum was built to stop. + +// `unreachable_pub` (denied workspace-wide) and `redundant_pub_crate` +// disagree about a private module's cross-module helpers: the first +// rejects `pub`, the second flags `pub(crate)`. `pub(crate)` is the one +// that states the real visibility, so the other lint is silenced here +// rather than the module being made public to satisfy it. +#![allow(clippy::redundant_pub_crate)] + +use std::collections::BTreeSet; + +use serde_json::{Map, Value}; + +use crate::strict::{defaulted_int, name_slot, optional_string}; +use crate::{OwnIrError, OwnIrErrorKind}; + +/// The closed event vocabulary of `protocol_functions[].events` — the `ev` +/// discriminator. Mirrors the flow-op rule (`OwnIR` §5): a present-but-unknown +/// value is rejected, never skipped. +const EVENT_KINDS: [&str; 6] = ["assign", "call", "if", "return", "throw", "while"]; + +/// Matcher vocabulary for `opens` / `closes` / `barriers` / `allow`. +const MATCHER_KINDS: [&str; 2] = ["assign", "call"]; + +fn shape(message: impl Into) -> OwnIrError { + OwnIrError::new(OwnIrErrorKind::Shape, message) +} + +fn vocabulary(message: impl Into) -> OwnIrError { + OwnIrError::new(OwnIrErrorKind::Vocabulary, message) +} + +/// Right types, legal vocabulary, and still meaningless — see the module doc. +fn well_formed(message: impl Into) -> OwnIrError { + OwnIrError::new(OwnIrErrorKind::WellFormedness, message) +} + +/// One event pattern, in the only form this checkpoint needs: something that can +/// be compared. The `opens in barriers` rule is a value comparison in the +/// reference (a frozen dataclass), so equality here has to mean the same thing — +/// `args` is a set, not a list, because the reference stores a `frozenset`. +#[derive(Debug, PartialEq, Eq)] +struct MatcherSpec { + kind: &'static str, + /// assign: the member name; call: the callee name. + target: String, + /// assign only — `None` means "any written value". + value: Option, + /// call only — empty means "any argument". + args: BTreeSet, +} + +/// Validate one `protocols[]` record and return the name it declares. +/// +/// # Errors +/// [`OwnIrError`] on any shape, vocabulary or identity violation, in the +/// reference parser's order. +pub(crate) fn validate_protocol(raw: &Value) -> Result<&str, OwnIrError> { + let Some(obj) = raw.as_object() else { + return Err(shape(format!("a protocol must be an object, got {raw}"))); + }; + let name = name_slot(obj, "name", "protocol")?; + let what = format!("protocol '{name}'"); + + // Presence is checked for BOTH before either is parsed, so a record missing + // one reports the requirement rather than a matcher error for the other. + if !obj.contains_key("opens") || !obj.contains_key("closes") { + return Err(shape(format!( + "{what}: 'opens' and 'closes' are both required" + ))); + } + // `require_value`: an opens/closes assign matcher must name the written + // boolean — "any write opens" is not a checkable protocol. + let opens = matcher(&obj["opens"], &format!("{what} 'opens'"), true)?; + matcher(&obj["closes"], &format!("{what} 'closes'"), true)?; + + let barriers = matchers(obj, "barriers", &what)?; + matchers(obj, "allow", &what)?; + + let exit_barriers = match obj.get("exit_barriers") { + None => true, + Some(Value::Bool(b)) => *b, + Some(other) => { + return Err(shape(format!( + "{what}: 'exit_barriers' must be a boolean, got {other}" + ))) + } + }; + if barriers.is_empty() && !exit_barriers { + return Err(well_formed(format!( + "{what}: no barriers and exit_barriers is false — the protocol can \ + never fire (a rule that structurally never fires is decoration)" + ))); + } + if barriers.contains(&opens) { + return Err(well_formed(format!( + "{what}: a barrier equals the 'opens' matcher — the open wins and \ + the barrier can never fire (re-entrancy checks are not supported yet)" + ))); + } + + let scope: Option<&Map> = match obj.get("scope") { + None => None, + Some(Value::Object(m)) => Some(m), + Some(other) => { + return Err(shape(format!( + "{what}: 'scope' must be an object, got {other}" + ))) + } + }; + match scope.and_then(|s| s.get("methods")) { + None => {} + Some(Value::Array(methods)) => { + for method in methods { + // A scope entry is a method NAME the protocol is filtered by, so + // an empty or mistyped one is an identity failure. The reference + // raises one message for this and for a non-array `methods`; the + // ledger separates them because a missing container and an + // unusable name are different defects. + if !method.as_str().is_some_and(|m| !m.is_empty()) { + return Err(OwnIrError::new( + OwnIrErrorKind::Identity, + format!("{what}: 'scope.methods' entries must be non-empty strings"), + )); + } + } + } + Some(other) => { + return Err(shape(format!( + "{what}: 'scope.methods' must be an array of non-empty strings, got {other}" + ))) + } + } + match obj.get("description") { + None | Some(Value::String(_)) => {} + Some(other) => { + return Err(shape(format!( + "{what}: 'description' must be a string, got {other}" + ))) + } + } + Ok(name) +} + +/// `barriers` / `allow`: an array of matchers, each parsed without the +/// `require_value` rule that only `opens`/`closes` carry. +fn matchers( + obj: &Map, + key: &str, + what: &str, +) -> Result, OwnIrError> { + let Some(v) = obj.get(key) else { + return Ok(Vec::new()); + }; + let Some(items) = v.as_array() else { + return Err(shape(format!("{what}: '{key}' must be an array, got {v}"))); + }; + items + .iter() + .map(|m| matcher(m, &format!("{what} {key}"), false)) + .collect() +} + +/// One matcher object. +fn matcher(raw: &Value, what: &str, require_value: bool) -> Result { + let Some(obj) = raw.as_object() else { + return Err(shape(format!("{what} must be an object, got {raw}"))); + }; + let kind = obj.get("kind").and_then(Value::as_str); + let Some(kind) = kind.filter(|k| MATCHER_KINDS.contains(k)) else { + let got = obj.get("kind").unwrap_or(&Value::Null); + return Err(vocabulary(format!( + "{what}: unknown matcher kind {got} — the vocabulary is {MATCHER_KINDS:?}" + ))); + }; + if kind == "assign" { + let target = name_slot(obj, "target", what)?.to_owned(); + let value = match obj.get("value") { + None | Some(Value::Null) => None, + Some(Value::Bool(b)) => Some(*b), + Some(other) => { + return Err(shape(format!( + "{what}: assign 'value' must be a boolean, got {other}" + ))) + } + }; + if require_value && value.is_none() { + return Err(shape(format!( + "{what}: an opens/closes assign matcher must state the written \ + boolean 'value' — 'any write' cannot open or close an obligation" + ))); + } + return Ok(MatcherSpec { + kind: "assign", + target, + value, + args: BTreeSet::new(), + }); + } + let callee = name_slot(obj, "callee", what)?.to_owned(); + let args = match obj.get("args") { + None => BTreeSet::new(), + Some(Value::Array(items)) if items.iter().all(Value::is_string) => items + .iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect(), + Some(_) => { + return Err(shape(format!( + "{what}: call 'args' must be an array of strings" + ))) + } + }; + Ok(MatcherSpec { + kind: "call", + target: callee, + value: None, + args, + }) +} + +/// Validate one `protocol_functions[]` record. +/// +/// # Errors +/// [`OwnIrError`] on any violation in the record or its event tree. +pub(crate) fn validate_method(raw: &Value) -> Result<(), OwnIrError> { + let Some(obj) = raw.as_object() else { + return Err(shape(format!( + "a protocol function must be an object, got {raw}" + ))); + }; + let name = name_slot(obj, "name", "protocol function")?; + let what = format!("protocol function '{name}'"); + match obj.get("file") { + None | Some(Value::String(_)) => {} + Some(other) => { + return Err(shape(format!( + "{what}: 'file' must be a string, got {other}" + ))) + } + } + events(obj.get("events"), &what) +} + +/// An ordered event list, recursive over `if` / `while`. +/// +/// Absent means empty; a present `null` is not a list and is rejected. +/// +/// Recursion here is bounded for a **parsed** document by `serde_json`'s +/// 128-level parse limit. For a value built in memory the bound is +/// [`crate::OwnIr::to_value`]'s iterative depth check, which runs before +/// serialization — measured, that is the constraint that actually binds: +/// `to_value` and `validate` abort at the same depth, so a depth counter +/// threaded through this function would never be the thing that fires. +fn events(raw: Option<&Value>, what: &str) -> Result<(), OwnIrError> { + let items: &[Value] = match raw { + None => &[], + Some(Value::Array(items)) => items, + Some(other) => { + return Err(shape(format!( + "{what}: events must be an array, got {other}" + ))) + } + }; + for event in items { + let Some(obj) = event.as_object() else { + return Err(shape(format!( + "{what}: each event must be an object, got {event}" + ))); + }; + let kind = obj.get("ev").and_then(Value::as_str); + let Some(kind) = kind.filter(|k| EVENT_KINDS.contains(k)) else { + let got = obj.get("ev").unwrap_or(&Value::Null); + return Err(vocabulary(format!( + "{what}: unknown protocol event {got} — the vocabulary is {EVENT_KINDS:?}" + ))); + }; + // The line is checked for every kind, before the per-kind fields. + defaulted_int(obj, "line", what)?; + match kind { + "assign" => { + name_slot(obj, "target", &format!("{what} assign"))?; + match obj.get("value") { + None | Some(Value::Null | Value::Bool(_)) => {} + Some(other) => { + return Err(shape(format!( + "{what}: assign 'value' must be a boolean or absent \ + (absent = opaque write), got {other}" + ))) + } + } + } + "call" => { + name_slot(obj, "callee", &format!("{what} call"))?; + optional_string(obj, "arg", what)?; + } + "if" => { + events(obj.get("then"), what)?; + events(obj.get("else"), what)?; + } + "while" => events(obj.get("body"), what)?, + // "return" / "throw" carry only the line, already checked. + _ => {} + } + } + Ok(()) +} diff --git a/rust/crates/own-ir/src/strict.rs b/rust/crates/own-ir/src/strict.rs new file mode 100644 index 00000000..bf20350e --- /dev/null +++ b/rust/crates/own-ir/src/strict.rs @@ -0,0 +1,549 @@ +//! The BR-D1 strict door, as a sequential validator over the **raw** document. +//! +//! # Why this is not `serde` +//! +//! The obvious Rust design is to let `Deserialize` be the gate: declare the +//! model precisely and let a failed deserialization mean "rejected". That was +//! the previous design here, and it is wrong for this door in two ways that +//! only a differential census makes visible. +//! +//! **It cannot answer *why*.** A `lifetime` enum rejects `"eternal"` perfectly +//! well — and reports it as [`OwnIrErrorKind::Shape`], when the contract +//! violated is a closed vocabulary. A `NonZeroU32` column rejects `0` and calls +//! that a type error, when it is the 1-based coordinate rule. #259 asks for a +//! matching error *class*, so the implementation mechanism must not be what +//! picks the semantic category. +//! +//! **It cannot answer *which first*.** BR-D1 fixes the order of checks, and the +//! spec notes the order "is observable through which error fires first". serde +//! visits fields in the order the *struct* declares and containers depth-first; +//! the reference visits sections in document-declaration order and, within a +//! section, interleaves shape and semantic checks per record. A document that +//! breaks a `components` shape rule and a `services` vocabulary rule has one +//! correct answer, and it is the components one. +//! +//! An earlier attempt kept serde and hoisted the semantic checks in front of it +//! — version gate, then every vocabulary/identity/location rule, then +//! deserialize. That passes section-local controls and fails every cross-section +//! ordering control, because "all semantics, then all shapes" is a third order +//! that matches neither implementation. There is no arrangement of two passes +//! that reproduces one interleaved pass; the interleaving has to be the code. +//! +//! # What serde is for now +//! +//! Construction. By the time [`crate::OwnIr::from_json`] deserializes, this +//! module has already accepted the document, so a `serde` failure afterwards is +//! not a rejection — it is a **hole in this validator**, reported as such and +//! asserted against by the replay test. +//! +//! # Shape of the port +//! +//! Not 47 transcribed `if`s: a handful of primitives that each encode one of +//! Python's access idioms, and one function per section that applies them in +//! the reference's order. +//! +//! | primitive | Python idiom | +//! |---|---| +//! | [`objects`] | `x = d.get(k, []); isinstance(x, list) and all(isinstance(i, dict))` | +//! | [`name_slot`] | `isinstance(v, str) and v` — a value other facts join on | +//! | [`optional_string`] | `v is not None and not isinstance(v, str)` — null tolerated | +//! | [`defaulted_string`] | `isinstance(d.get(k, "?"), str)` — null rejected | +//! | [`defaulted_int`] | `isinstance(x, int) and not isinstance(x, bool)` | +//! | [`string_array`] | `isinstance(x, list) and all(isinstance(i, str))` | +//! | [`column`] | `_check_column` — the 1-based contract (#317) | +//! | [`sites`] | the `{type, file, line}` call-site record | +//! +//! The distinction between the two string primitives is the one place a single +//! "policy for optional fields" would silently be wrong: `resource` rejects an +//! explicit `null` and `source_provenance` accepts it, because the reference +//! writes one as `isinstance(...)` on a defaulted read and the other as `is not +//! None and ...`. They shared a bug once; they do not share a contract. + +// `unreachable_pub` (denied workspace-wide) and `redundant_pub_crate` +// disagree about a private module's cross-module helpers: the first +// rejects `pub`, the second flags `pub(crate)`. `pub(crate)` is the one +// that states the real visibility, so the other lint is silenced here +// rather than the module being made public to satisfy it. +#![allow(clippy::redundant_pub_crate)] + +use serde_json::{Map, Value}; + +use crate::{OwnIrError, OwnIrErrorKind, KNOWN_RESOURCE_KINDS}; + +type Checked = Result<(), OwnIrError>; + +const LIFETIMES: [&str; 3] = ["scoped", "singleton", "transient"]; +const PARAM_EFFECTS: [&str; 4] = ["borrow", "borrow_mut", "consume", "plain"]; + +fn shape(message: impl Into) -> OwnIrError { + OwnIrError::new(OwnIrErrorKind::Shape, message) +} + +fn identity(message: impl Into) -> OwnIrError { + OwnIrError::new(OwnIrErrorKind::Identity, message) +} + +fn vocabulary(message: impl Into) -> OwnIrError { + OwnIrError::new(OwnIrErrorKind::Vocabulary, message) +} + +/// Is this an integer in Python's sense — `int` and not `bool`? +/// +/// `true` is called out rather than folded in with other non-numbers: it is the +/// trap the reference guards explicitly, because a Python `bool` *is* an `int` +/// and `True` would otherwise read as `1`. +fn is_integer(v: &Value) -> bool { + match v { + Value::Number(n) => n.is_i64() || n.is_u64(), + _ => false, + } +} + +/// `d.get(key, [])` read as a list of objects. Absent is empty; a present +/// `null` is **not** absent and is rejected, exactly as `isinstance(None, list)` +/// fails in the reference. +fn objects<'a>( + obj: &'a Map, + key: &str, + message: &str, +) -> Result>, OwnIrError> { + let Some(v) = obj.get(key) else { + return Ok(Vec::new()); + }; + let Some(items) = v.as_array() else { + return Err(shape(message.to_owned())); + }; + items + .iter() + .map(|i| i.as_object().ok_or_else(|| shape(message.to_owned()))) + .collect() +} + +/// `d.get(key, [])` read as a bare list — the element type is somebody else's +/// contract. `protocols` and `protocol_functions` are checked this way, because +/// the reference delegates each record to the shared obligation parser. +fn list<'a>( + obj: &'a Map, + key: &str, + message: &str, +) -> Result<&'a [Value], OwnIrError> { + match obj.get(key) { + None => Ok(&[]), + Some(Value::Array(items)) => Ok(items), + Some(_) => Err(shape(message.to_owned())), + } +} + +/// A **name slot**: a value some other fact joins on. Absent, `null`, empty or +/// non-string are one defect — the name cannot be used to join — and all report +/// [`OwnIrErrorKind::Identity`]. +pub(crate) fn name_slot<'a>( + obj: &'a Map, + key: &str, + what: &str, +) -> Result<&'a str, OwnIrError> { + match obj.get(key) { + Some(Value::String(s)) if !s.is_empty() => Ok(s), + _ => Err(identity(format!( + "{what}: '{key}' must be a non-empty string" + ))), + } +} + +/// `v = d.get(key); if v is not None and not isinstance(v, str)` — absent *and* +/// an explicit `null` both pass. +pub(crate) fn optional_string(obj: &Map, key: &str, what: &str) -> Checked { + match obj.get(key) { + None | Some(Value::Null | Value::String(_)) => Ok(()), + Some(other) => Err(shape(format!( + "{what} '{key}' must be a string, got {other}" + ))), + } +} + +/// `isinstance(d.get(key, "?"), str)` — absent passes on the default, a present +/// `null` does not. +fn defaulted_string(obj: &Map, key: &str, what: &str) -> Checked { + match obj.get(key) { + None | Some(Value::String(_)) => Ok(()), + Some(other) => Err(shape(format!( + "{what} '{key}' must be a string, got {other}" + ))), + } +} + +/// `x = d.get(key, 0); isinstance(x, int) and not isinstance(x, bool)`. +pub(crate) fn defaulted_int(obj: &Map, key: &str, what: &str) -> Checked { + match obj.get(key) { + None => Ok(()), + Some(v) if is_integer(v) => Ok(()), + Some(other) => Err(shape(format!( + "{what} '{key}' must be an integer, got {other}" + ))), + } +} + +/// `isinstance(x, list) and all(isinstance(i, str) for i in x)`. +fn string_array(obj: &Map, key: &str, what: &str) -> Checked { + let Some(v) = obj.get(key) else { + return Ok(()); + }; + let ok = v + .as_array() + .is_some_and(|items| items.iter().all(Value::is_string)); + if ok { + Ok(()) + } else { + Err(shape(format!("{what} '{key}' must be an array of strings"))) + } +} + +/// The 1-based source-column contract (#317). +/// +/// A column is a positive integer or it is absent — and, unlike a `line`, an +/// explicit `null` **is** accepted, because the reference returns early on +/// `None`. `0` is rejected rather than read as "unknown": SARIF columns start at +/// 1, so a `0` is a producer bug, and silently treating it as absent would hide +/// the bug while looking correct. +fn column(value: Option<&Value>, what: &str) -> Checked { + let Some(v) = value else { return Ok(()) }; + if v.is_null() { + return Ok(()); + } + let ok = is_integer(v) && v.as_i64().is_some_and(|i| i >= 1); + if ok { + return Ok(()); + } + Err(OwnIrError::new( + OwnIrErrorKind::Location, + format!("{what} 'column' must be a 1-based integer or absent, got {v}"), + )) +} + +/// `column` on every flow op, recursing through `then` / `else` / `body`. +/// +/// A non-list body is **skipped**, not rejected, and so is a non-object op: the +/// reference returns early in both cases. Tightening that would be a +/// Rust-only rejection of facts that analyse today. +/// +/// Recursion here is bounded for a **parsed** document: `serde_json` caps +/// nesting at 128 levels, so a body deep enough to exhaust the stack never +/// becomes a [`Value`]. A value built **in memory** has no such bound, which is +/// why [`crate::OwnIr::to_value`] depth-checks before serializing — and why +/// that check is iterative. +fn flow_columns(nodes: Option<&Value>, what: &str) -> Checked { + let Some(Value::Array(items)) = nodes else { + return Ok(()); + }; + for node in items { + let Some(op) = node.as_object() else { continue }; + let label = op + .get("op") + .map_or_else(|| format!("{what} op"), |name| format!("{what} op {name}")); + column(op.get("column"), &label)?; + for key in ["then", "else", "body"] { + flow_columns(op.get(key), what)?; + } + } + Ok(()) +} + +/// An array of `{type, file, line}` call-site records (DI004 / DI005). Every +/// field is defaulted, so `{}` is a legal site; the reference folds the whole +/// check into one `all(...)`, so any violation is one shape failure. +fn sites(obj: &Map, key: &str, what: &str) -> Checked { + let Some(v) = obj.get(key) else { + return Ok(()); + }; + let ok = v.as_array().is_some_and(|items| { + items.iter().all(|s| { + s.as_object().is_some_and(|site| { + matches!(site.get("type"), None | Some(Value::String(_))) + && matches!(site.get("file"), None | Some(Value::String(_))) + && site.get("line").map_or(true, is_integer) + }) + }) + }); + if ok { + Ok(()) + } else { + Err(shape(format!( + "{what} '{key}' must be an array of {{type:str, file:str, line:int}} objects" + ))) + } +} + +/// Run the whole strict door, in the reference's order. +/// +/// Sections are visited in declaration order and each is finished before the +/// next begins — that is the property the cross-section ordering controls pin. +pub(crate) fn validate_document(obj: &Map) -> Checked { + version(obj)?; + components(obj)?; + services(obj)?; + effects(obj)?; + functions(obj)?; + protocols(obj)?; + protocol_functions(obj) +} + +/// The version gate, first: a vocabulary mismatch makes every later shape check +/// meaningless. An absent field means the current version — the only producers +/// that omit it predate versioning. +fn version(obj: &Map) -> Checked { + let Some(v) = obj.get("ownir_version") else { + return Ok(()); + }; + let ver = if is_integer(v) { v.as_i64() } else { None }; + let Some(ver) = ver else { + return Err(OwnIrError::new( + OwnIrErrorKind::Version, + format!("OwnIR 'ownir_version' must be an integer, got {v}"), + )); + }; + if ver != crate::OWNIR_VERSION { + return Err(OwnIrError::new( + OwnIrErrorKind::Version, + format!( + "OwnIR facts are schema v{ver}, but this core understands \ + v{}. Build the extractor and the core from the same commit — \ + the OwnIR fact vocabulary changed between the version that \ + produced this file and the one reading it.", + crate::OWNIR_VERSION + ), + )); + } + Ok(()) +} + +fn components(obj: &Map) -> Checked { + let comps = objects( + obj, + "components", + "OwnIR 'components' must be a JSON array of objects", + )?; + for component in comps { + let subs = objects( + component, + "subscriptions", + "each component's 'subscriptions' must be objects", + )?; + for sub in subs { + // Shape before vocabulary: `resource` must BE a string before its + // value can be tested against the closed set. An absent field + // defaults to "subscription", which is known; a present `null` is + // not a string and fails here. + match sub.get("resource") { + None | Some(Value::String(_)) => {} + Some(other) => { + return Err(shape(format!( + "subscription 'resource' must be a string, got {other}" + ))) + } + } + // IR4: a present-but-unknown kind changes routing, so the strict + // door rejects it rather than let it mis-route. The lowering door + // keeps its own copy of this rule (#294 OD-2) for callers that + // bypass this loader entirely — two doors, not one duplicated check. + if let Some(kind) = sub.get("resource").and_then(Value::as_str) { + if !KNOWN_RESOURCE_KINDS.contains(&kind) { + return Err(vocabulary(format!( + "unknown resource kind {kind:?} — a new kind is a \ + vocabulary change that must bump OWNIR_VERSION" + ))); + } + } + column(sub.get("column"), "subscription")?; + optional_string(sub, "type", "subscription")?; + optional_string(sub, "source_type", "subscription")?; + optional_string(sub, "source_provenance", "subscription")?; + optional_string(sub, "ignore_reason", "subscription")?; + } + } + Ok(()) +} + +fn services(obj: &Map) -> Checked { + let svcs = objects( + obj, + "services", + "OwnIR 'services' must be a JSON array of objects", + )?; + for svc in svcs { + // Lifetime BEFORE name — the reference's order, and observable: a + // record breaking both is a vocabulary failure, not an identity one. + // There is no default, so an absent lifetime is `None`, which is + // outside the closed set exactly like a misspelt one. + let known = svc + .get("lifetime") + .and_then(Value::as_str) + .is_some_and(|l| LIFETIMES.contains(&l)); + if !known { + let got = svc.get("lifetime").unwrap_or(&Value::Null); + return Err(vocabulary(format!( + "service 'lifetime' must be one of {LIFETIMES:?}, got {got}" + ))); + } + name_slot(svc, "name", "service")?; + string_array(svc, "deps", "service")?; + string_array(svc, "weak_deps", "service")?; + string_array(svc, "root_resolves", "service")?; + defaulted_string(svc, "file", "service")?; + defaulted_int(svc, "line", "service")?; + defaulted_string(svc, "ctor_file", "service")?; + defaulted_int(svc, "ctor_line", "service")?; + defaulted_string(svc, "ctor_type", "service")?; + sites(svc, "root_resolve_sites", "service")?; + string_array(svc, "scope_cached", "service")?; + sites(svc, "scope_cache_sites", "service")?; + } + Ok(()) +} + +fn effects(obj: &Map) -> Checked { + let effs = objects( + obj, + "effects", + "OwnIR 'effects' must be a JSON array of objects", + )?; + for eff in effs { + string_array(eff, "deps", "effect")?; + match eff.get("io") { + None | Some(Value::Bool(_)) => {} + Some(other) => { + return Err(shape(format!("effect 'io' must be a boolean, got {other}"))) + } + } + defaulted_int(eff, "line", "effect")?; + let binds = objects( + eff, + "bindings", + "effect 'bindings' must be a JSON array of objects", + )?; + for binding in binds { + defaulted_string(binding, "name", "binding")?; + defaulted_string(binding, "init", "binding")?; + string_array(binding, "refs", "binding")?; + defaulted_int(binding, "line", "binding")?; + } + } + Ok(()) +} + +fn functions(obj: &Map) -> Checked { + let fns = objects( + obj, + "functions", + "OwnIR 'functions' must be a JSON array of objects", + )?; + for function in fns { + optional_string(function, "sig", "function")?; + // The BODY's columns precede `params` — the least obvious edge in the + // door, because params read like the more primitive thing. + flow_columns(function.get("body"), "function body")?; + let params = objects( + function, + "params", + "a function's 'params' must be a JSON array of objects", + )?; + for param in params { + name_slot(param, "name", "parameter")?; + defaulted_int(param, "line", "parameter")?; + column(param.get("column"), "parameter")?; + match param.get("effect") { + None | Some(Value::Null) => {} + Some(v) if v.as_str().is_some_and(|e| PARAM_EFFECTS.contains(&e)) => {} + Some(other) => { + return Err(vocabulary(format!( + "parameter 'effect' must be one of {PARAM_EFFECTS:?}, got {other}" + ))) + } + } + } + } + Ok(()) +} + +/// `protocols[]` — record grammar from the shared parser, then the identity +/// invariant two individually valid records can only violate together. +fn protocols(obj: &Map) -> Checked { + let protos = list( + obj, + "protocols", + "OwnIR 'protocols' must be a JSON array of objects", + )?; + let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); + for raw in protos { + let name = crate::protocol::validate_protocol(raw)?; + // The name is the identity the bridge maps verdicts back by (IR5); two + // protocols sharing one would make that mapping ambiguous and can + // collapse distinct findings in the dedup. + if !seen.insert(name) { + return Err(identity(format!( + "duplicate protocol name '{name}' — protocol names are the \ + identity findings map back by and must be unique" + ))); + } + } + Ok(()) +} + +fn protocol_functions(obj: &Map) -> Checked { + let pfns = list( + obj, + "protocol_functions", + "OwnIR 'protocol_functions' must be a JSON array of objects", + )?; + for raw in pfns { + crate::protocol::validate_method(raw)?; + } + Ok(()) +} + +/// The nesting depth beyond which a raw [`Value`] is refused — the one +/// normative depth number in this crate. +/// +/// Deliberately `serde_json`'s own parse limit: a document that could be +/// *parsed* never exceeds it, so this bound rejects nothing +/// [`crate::OwnIr::from_json`] would accept. It exists for values built **in +/// memory**, which never passed a parser and therefore carry no bound at all. +/// +/// It matches the parser rather than sitting just under some observed overflow +/// point. Where an unguarded serialization happens to abort depends on stack +/// size, build profile and platform — pinning a contract to that would be +/// pinning it to one machine. +pub(crate) const MAX_VALUE_DEPTH: usize = 128; + +/// Depth of a raw value, measured with an explicit stack. +/// +/// Iterative on purpose: a recursive depth check would be the failure it is +/// meant to prevent, and would abort the process rather than return an error — +/// a stack overflow is not catchable. +pub(crate) fn check_depth(value: &Value, what: &str) -> Checked { + let mut stack: Vec<(&Value, usize)> = vec![(value, 1)]; + while let Some((v, depth)) = stack.pop() { + if depth > MAX_VALUE_DEPTH { + return Err(shape(format!( + "{what}: nested more than {MAX_VALUE_DEPTH} levels deep" + ))); + } + let Some(next) = depth.checked_add(1) else { + return Err(shape(format!("{what}: nesting depth overflowed"))); + }; + match v { + Value::Array(items) => stack.extend(items.iter().map(|i| (i, next))), + Value::Object(map) => stack.extend(map.values().map(|i| (i, next))), + _ => {} + } + } + Ok(()) +} + +/// [`check_depth`] over every value in a `serde(flatten)` `extra` map, which is +/// itself one level of nesting. +pub(crate) fn check_map_depth(map: &Map, what: &str) -> Checked { + for value in map.values() { + check_depth(value, what)?; + } + Ok(()) +} diff --git a/rust/crates/own-ir/tests/roundtrip.rs b/rust/crates/own-ir/tests/roundtrip.rs index 27aa7fe3..abcb2ccb 100644 --- a/rust/crates/own-ir/tests/roundtrip.rs +++ b/rust/crates/own-ir/tests/roundtrip.rs @@ -47,7 +47,7 @@ fn round_trips_every_python_fixture() { fn version_gate_rejects_future_schema() { let err = OwnIr::from_json(r#"{"ownir_version": 1}"#).expect_err("v1 must be rejected"); assert!( - err.0.contains("schema v1") && err.0.contains(&format!("v{OWNIR_VERSION}")), + err.message.contains("schema v1") && err.message.contains(&format!("v{OWNIR_VERSION}")), "gate message must name both versions: {err}" ); } diff --git a/rust/crates/own-ir/tests/validation_replay.rs b/rust/crates/own-ir/tests/validation_replay.rs new file mode 100644 index 00000000..47765aa7 --- /dev/null +++ b/rust/crates/own-ir/tests/validation_replay.rs @@ -0,0 +1,393 @@ +//! Zero-Python replay of the BR-D1 strict-door acceptance language +//! (`tests/fixtures/ownir_validation.json`, authoritative via +//! `python tests/test_ownir_validation_fixtures.py --write`) — P-022 step 6b, +//! #259 checkpoint 1. +//! +//! The checkpoint is not "port 47 `if`s". It is: **prove the two loaders accept +//! the same language and classify rejections the same way**. So the test is a +//! matrix, not a pass/fail: +//! +//! | | meaning | +//! |---|---| +//! | Python accept / Rust accept | agreed | +//! | Python reject / Rust reject, same kind | agreed | +//! | Python reject / **Rust accept** | *critical permissiveness* — the strict door is the gate over untrusted extractor output | +//! | Python accept / **Rust reject** | *over-strictness* — not a hole, but a post-cutover outage: facts that analyse today would stop | +//! | kind mismatch | both reject, but disagree about **why** | +//! +//! All three failure rows must be zero. The asymmetry in *severity* is real and +//! reported, but it is not an asymmetry in *acceptability*. +//! +//! What is compared is the **kind**, never the message. The reference funnels +//! every rejection into one `OwnIRError` whose strings are a human-facing +//! presentation aid; freezing them would make this a byte-comparison of two +//! languages' English. +//! +//! Equally: the expected kind is declared by the ledger and confirmed by the +//! oracle only as accept/reject. It is **not** derived from the reference's +//! message text — that would abandon message parity at the front door and +//! rebuild it as regex parity at the back. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use own_ir::{OwnIr, OwnIrErrorKind}; +use serde_json::Value; + +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/ownir_validation.json" +); + +const SCHEMA_VERSION: u64 = 1; + +fn load() -> Value { + let raw = std::fs::read_to_string(FIXTURE).expect( + "fixture missing — regenerate: python tests/test_ownir_validation_fixtures.py --write", + ); + let root: Value = serde_json::from_str(&raw).expect("ownir_validation.json parses"); + assert_eq!( + root.get("schema_version").and_then(Value::as_u64), + Some(SCHEMA_VERSION), + "fixture schema_version changed — a reviewed contract, not a passing reshape" + ); + root +} + +fn cases(root: &Value) -> &Vec { + root.get("cases") + .and_then(Value::as_array) + .expect("'cases' array") +} + +/// The document text to feed the loader. +/// +/// A control carrying `raw: true` supplies the file's literal TEXT (the only +/// way to reach the JSON-parse branch); anything else is a value to serialize. +/// The flag is explicit rather than inferred from the document being a string — +/// that heuristic mis-encoded the JSON document `"hello"` as the unparseable +/// bytes `hello`, and the resulting category mismatch looked like a port bug. +fn document_text(case: &Value) -> String { + let doc = case.get("document").expect("case 'document'"); + if case.get("raw").and_then(Value::as_bool) == Some(true) { + return doc + .as_str() + .expect("a raw control's document is text") + .to_owned(); + } + serde_json::to_string(doc).expect("document serializes") +} + +#[derive(Default, Debug)] +struct Matrix { + agreed_accept: usize, + agreed_reject: usize, + rust_only_accept: usize, + rust_only_reject: usize, + kind_mismatch: usize, +} + +#[test] +fn the_two_loaders_accept_the_same_language() { + let root = load(); + let mut m = Matrix::default(); + let mut permissive: Vec = Vec::new(); + let mut over_strict: Vec = Vec::new(); + let mut mismatched: Vec = Vec::new(); + + for case in cases(&root) { + let name = case + .get("name") + .and_then(Value::as_str) + .expect("case 'name'"); + let python_rejects = case.get("verdict").and_then(Value::as_str) == Some("reject"); + let expected_kind = case.get("category").and_then(Value::as_str); + + match (python_rejects, OwnIr::from_json(&document_text(case))) { + (false, Ok(_)) => m.agreed_accept = m.agreed_accept.saturating_add(1), + (true, Err(e)) => { + let expected = expected_kind.expect("a rejected control declares its kind"); + if e.kind.as_str() == expected { + m.agreed_reject = m.agreed_reject.saturating_add(1); + } else { + m.kind_mismatch = m.kind_mismatch.saturating_add(1); + mismatched.push(format!( + "{name}: reference says {expected}, port says {} ({})", + e.kind.as_str(), + e.message + )); + } + } + (true, Ok(_)) => { + m.rust_only_accept = m.rust_only_accept.saturating_add(1); + permissive.push(name.to_owned()); + } + (false, Err(e)) => { + m.rust_only_reject = m.rust_only_reject.saturating_add(1); + over_strict.push(format!("{name}: {}", e.message)); + } + } + } + + // One assertion covering all three failure rows, not three sequential ones. + // Sequential asserts report only the first non-empty row, so a census that + // opens 58 permissive cases and 9 category mismatches at once looks like a + // permissiveness problem alone — and the second round only becomes visible + // after the first is fixed. Measuring the whole matrix in one pass is the + // difference between one RED reading and a series of them. + let rows = [ + ( + m.rust_only_accept, + "CRITICAL permissiveness — the strict door is the gate over \ + untrusted extractor output, and these documents are refused by the \ + reference but analysed by the port", + &permissive, + ), + ( + m.rust_only_reject, + "over-strictness — not a hole, but facts the reference analyses \ + today would stop being analysable after cutover", + &over_strict, + ), + ( + m.kind_mismatch, + "category mismatch — both loaders reject, but disagree about WHY. \ + Accept/reject parity can be green while the taxonomy is \ + decorative; this is what stops that", + &mismatched, + ), + ]; + let report = rows + .iter() + .filter(|(count, _, _)| *count > 0) + .map(|(count, heading, names)| { + format!("\n\n{heading} ({count}):\n {}", names.join("\n ")) + }) + .collect::>() + .concat(); + assert!( + report.is_empty(), + "the two loaders do not accept the same language.\nmatrix: {m:?}{report}" + ); + assert_eq!( + m.agreed_accept.saturating_add(m.agreed_reject), + cases(&root).len(), + "every control must land in an agreed row: {m:?}" + ); +} + +#[test] +fn no_control_escapes_into_serde() { + // The architecture's central claim is that `strict` decides accept/reject + // and serde only CONSTRUCTS. That claim is falsifiable: if a document + // survives validation and serde still refuses it, some rule lives in the + // model instead of the validator — the category would be whatever serde + // felt like, and the ordering contract would be bypassed entirely. + // + // `from_json` marks exactly that case, and this asserts no control reaches + // it. Without this the design would degrade silently back into "serde is + // the gate" one undeclared field at a time. + let root = load(); + let mut escaped: Vec = Vec::new(); + for case in cases(&root) { + if let Err(e) = OwnIr::from_json(&document_text(case)) { + if e.message.contains(own_ir::VALIDATOR_HOLE) { + let name = case.get("name").and_then(Value::as_str).unwrap_or("?"); + escaped.push(format!("{name}: {}", e.message)); + } + } + } + assert!( + escaped.is_empty(), + "the strict validator accepted documents serde then refused, so these \ + rules live in the MODEL rather than the validator — their category and \ + their ordering are both accidental:\n {}", + escaped.join("\n ") + ); +} + +#[test] +fn the_acceptance_controls_make_the_rejections_discriminating() { + // A ledger of rejections alone would pass against a loader that refuses + // everything. The valid twins are what give the rejections meaning, so the + // count is asserted rather than assumed. + let root = load(); + let accepted = cases(&root) + .iter() + .filter(|c| c.get("verdict").and_then(Value::as_str) == Some("accept")) + .count(); + assert!( + accepted >= 5, + "only {accepted} acceptance control(s); the rejections are not \ + discriminating without valid twins" + ); +} + +#[test] +fn every_declared_category_is_exercised_by_a_control() { + // The taxonomy must not outgrow its evidence. A variant no control reaches + // is a claim about the contract that nothing backs — which is exactly why + // there is no `Reference` variant. + let root = load(); + let declared = root + .get("categories") + .and_then(Value::as_object) + .expect("'categories'"); + for name in declared.keys() { + let used = cases(&root) + .iter() + .any(|c| c.get("category").and_then(Value::as_str) == Some(name.as_str())); + assert!( + used, + "category {name:?} is declared but no control exercises it" + ); + } + // …and the port must be able to name every one of them. This list is + // exhaustive by hand rather than derived, so adding a variant without a + // control — or a control without a variant — fails here. + for kind in [ + OwnIrErrorKind::Json, + OwnIrErrorKind::Version, + OwnIrErrorKind::Shape, + OwnIrErrorKind::Vocabulary, + OwnIrErrorKind::Identity, + OwnIrErrorKind::Location, + OwnIrErrorKind::WellFormedness, + ] { + assert!( + declared.contains_key(kind.as_str()), + "the port has a kind {:?} the ledger does not declare", + kind.as_str() + ); + } +} + +#[test] +fn malformed_input_never_panics() { + // Facts are external input. #259 cp1 requires "no panic on malformed + // JSON/OwnIR", so this feeds shapes no control covers — deep nesting, wrong + // types at every level, and the empty document — and asserts only that the + // loader RETURNS rather than unwinds. + let hostile = [ + "", + " ", + "null", + "0", + "[]", + "{}", + "{\"ownir_version\":}", + "{\"components\":[{\"subscriptions\":[{\"column\":1e400}]}]}", + "{\"services\":[[[[[]]]]]}", + "{\"functions\":[{\"params\":[{\"name\":null}]}]}", + "{\"protocols\":[null,null]}", + "{\"ownir_version\":-9223372036854775808}", + ]; + for text in hostile { + // Panicking here fails the test by unwinding; the assertion is that we + // reach this line at all for every input. + let _ = OwnIr::from_json(text); + } +} + +#[test] +fn a_deep_in_memory_value_is_refused_rather_than_aborting() { + // `serde_json::to_value` recurses over a `Value`. On a deep enough one it + // does not return an error — it overflows the stack, which aborts the + // process and cannot be caught. `from_json` was never exposed (the parser + // caps nesting at 128); `validate()` is, because it serializes a value the + // caller built in memory. + // + // Depth 200 is chosen deliberately: past the 128 guard, and well inside the + // range where the value can still be built and dropped. See the scope note + // below for why it is not larger. + let mut node = serde_json::json!({"ev": "return", "line": 1}); + for _ in 0..200 { + node = serde_json::json!({"ev": "if", "line": 1, "then": [node]}); + } + let doc = OwnIr { + protocol_functions: Some(vec![serde_json::json!({"name": "M", "events": [node]})]), + ..OwnIr::default() + }; + + let err = doc.validate().expect_err("a 200-deep tree must be refused"); + assert_eq!(err.kind, OwnIrErrorKind::Shape, "{}", err.message); + assert!( + err.message.contains("nested more than"), + "expected the depth guard, got: {}", + err.message + ); + // …and `to_value` refuses it directly, since that is where the hazard is. + // The guard lives there, NOT in the validator's own recursion: measured, + // `to_value` and `validate` abort at the SAME depth, so a counter threaded + // through `events`/`flow_columns` would never be the check that fires. + assert!(doc.to_value().is_err(), "to_value must refuse it too"); + + // SCOPE NOTE. What the guard promises is exactly this: `to_value()` and + // `validate()` refuse a too-deep value and RETURN, instead of aborting + // inside the serializer. It does not promise that any `Value` a caller + // managed to construct is safe to hold — `serde_json::Value` has a + // recursive `Drop`, so a sufficiently deep one aborts on release before any + // method of this crate is reached. Guaranteeing that would mean not + // representing facts as `serde_json::Value` at all: a representation + // change, and outside cp1. + // + // (Such a case cannot be asserted here anyway — it would abort while the + // test tears down, so the test could never report. Hence 200, not more.) +} + +#[test] +fn the_depth_guard_never_fires_on_a_document_from_json_accepts() { + // The guard is set to serde_json's own parse limit, so it must be + // unreachable for anything that survived parsing — otherwise it is not a + // stack-overflow bound, it is a new rejection rule with no counterpart in + // the reference. + // + // The ledger alone cannot check this: every control is a handful of levels + // deep, so a guard set to 16 would pass against all 193 of them. Measured — + // that mutation SURVIVED until a deep document was added here. + // + // …and a FIXED deep document is not enough either. At 50 wrappers, a guard + // anywhere between 51 and the parser's own ceiling still passes while + // rejecting documents the door accepts. So the boundary is DISCOVERED: walk + // depth upward until `from_json` refuses, then require the deepest document + // it accepted to survive `to_value`. That is the no-new-rejection contract + // stated exactly, instead of a sample that happens to sit under it. + let build = |depth: usize| { + let mut node = serde_json::json!({"ev": "return", "line": 1}); + for _ in 0..depth { + node = serde_json::json!({"ev": "if", "line": 1, "then": [node]}); + } + serde_json::to_string(&serde_json::json!({ + "ownir_version": 0, + "protocol_functions": [{"name": "M", "events": [node]}] + })) + .expect("serializes") + }; + + // The parser's cap is well under this; the ceiling only stops a runaway. + let mut deepest_accepted: Option<(usize, OwnIr)> = None; + for depth in 1..512 { + match OwnIr::from_json(&build(depth)) { + Ok(doc) => deepest_accepted = Some((depth, doc)), + Err(_) => break, + } + } + let (depth, doc) = deepest_accepted.expect( + "`from_json` refused even a one-level protocol tree — the parse \ + boundary cannot be located, so this test proves nothing", + ); + assert!( + doc.to_value().is_ok(), + "the depth guard refused a {depth}-level document that the strict door \ + ACCEPTED — the guard is tighter than the parser, which makes it a new \ + rejection rule rather than an overflow bound" + ); + + // …and every ledger control, for the same reason at ordinary depths. + let root = load(); + for case in cases(&root) { + if let Ok(doc) = OwnIr::from_json(&document_text(case)) { + let name = case.get("name").and_then(Value::as_str).unwrap_or("?"); + assert!(doc.to_value().is_ok(), "{name}: refused by the depth guard"); + } + } +} diff --git a/tests/fixtures/ownir_validation.json b/tests/fixtures/ownir_validation.json new file mode 100644 index 00000000..e5bef566 --- /dev/null +++ b/tests/fixtures/ownir_validation.json @@ -0,0 +1,4184 @@ +{ + "comment": "GENERATED by tests/test_ownir_validation_fixtures.py --write; do not edit. Python (ownlang.ownir.load) is authoritative. The BR-D1 strict-door acceptance language for P-022 step 6b, #259 checkpoint 1. Compared across languages: accepted/rejected and, on rejection, the CATEGORY -- never the message text, which is a human-facing presentation aid in the reference.", + "schema_version": 1, + "categories": { + "json": "the document is not JSON at all", + "version": "the `ownir_version` gate — type or value", + "shape": "right place, wrong JSON type or container shape", + "vocabulary": "right JSON type, value outside a closed set", + "identity": "a name slot — empty, mistyped, or duplicated", + "location": "a source coordinate violating the 1-based contract", + "well_formedness": "right types, legal vocabulary, and the record still cannot mean anything" + }, + "totals": { + "cases": 193, + "accepted": 31, + "rejected": 162, + "by_category": { + "accepted": 31, + "identity": 22, + "json": 2, + "location": 20, + "shape": 91, + "version": 7, + "vocabulary": 18, + "well_formedness": 2 + } + }, + "cases": [ + { + "name": "accept-empty-object", + "why": "the minimal document: absent `ownir_version` means current, and every section is optional", + "section": "root", + "document": {}, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-explicit-version", + "why": "the current version stated explicitly", + "section": "version", + "document": { + "ownir_version": 0 + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-all-sections-empty", + "why": "every known section present but empty — shape-valid, nothing to check", + "section": "root", + "document": { + "ownir_version": 0, + "components": [], + "services": [], + "effects": [], + "functions": [], + "protocols": [], + "protocol_functions": [] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-unknown-top-level-key", + "why": "an unrecognised TOP-LEVEL key is additive and accepted; the strict door gates known vocabulary, it is not a closed-world schema", + "section": "root", + "document": { + "ownir_version": 0, + "future_section": [ + { + "whatever": 1 + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-subscription-defaults", + "why": "a subscription with no `resource` defaults to 'subscription', which is a known kind", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + {} + ] + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-every-known-resource-kind", + "why": "all eight known kinds in one document — the acceptance side of the closed vocabulary", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "resource": "capture" + }, + { + "resource": "disposable" + }, + { + "resource": "local-disposable" + }, + { + "resource": "pool" + }, + { + "resource": "subscribe" + }, + { + "resource": "subscription" + }, + { + "resource": "timer" + }, + { + "resource": "unresolved-subscription" + } + ] + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-column-one", + "why": "column 1 is the smallest legal column — the boundary just inside the 1-based contract", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "column": 1 + } + ] + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-every-lifetime", + "why": "all three DI lifetimes accepted", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "A", + "lifetime": "singleton" + }, + { + "name": "B", + "lifetime": "scoped" + }, + { + "name": "C", + "lifetime": "transient" + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-every-param-effect", + "why": "all four parameter effects accepted", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": [ + { + "name": "a", + "effect": "plain" + }, + { + "name": "b", + "effect": "borrow" + }, + { + "name": "c", + "effect": "borrow_mut" + }, + { + "name": "d", + "effect": "consume" + } + ] + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-param-effect-absent", + "why": "`effect` is optional — absent is not the same as an unknown value", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": [ + { + "name": "a" + } + ] + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "json-not-parseable", + "why": "a truncated document is not JSON at all — rejected before any shape check can run", + "section": "json", + "document": "{not json", + "raw": true, + "verdict": "reject", + "category": "json", + "message": " is not valid JSON: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)" + }, + { + "name": "root-array", + "why": "the root must be an object, not an array", + "section": "root", + "document": [ + 1, + 2, + 3 + ], + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR root must be a JSON object" + }, + { + "name": "root-string", + "why": "nor a bare string", + "section": "root", + "document": "hello", + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR root must be a JSON object" + }, + { + "name": "root-null", + "why": "nor null", + "section": "root", + "document": null, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR root must be a JSON object" + }, + { + "name": "version-mismatch", + "why": "a different schema version makes every later check meaningless", + "section": "version", + "document": { + "ownir_version": 99 + }, + "raw": false, + "verdict": "reject", + "category": "version", + "message": "OwnIR facts are schema v99, but this core understands v0. Build the Roslyn extractor and the Python core from the same commit — the OwnIR fact vocabulary changed between the version that produced this file and the one reading it." + }, + { + "name": "version-string", + "why": "`ownir_version` must be an integer", + "section": "version", + "document": { + "ownir_version": "0" + }, + "raw": false, + "verdict": "reject", + "category": "version", + "message": "OwnIR 'ownir_version' must be an integer, got '0'" + }, + { + "name": "version-bool", + "why": "the bool-is-int trap: `True` would otherwise read as version 1", + "section": "version", + "document": { + "ownir_version": true + }, + "raw": false, + "verdict": "reject", + "category": "version", + "message": "OwnIR 'ownir_version' must be an integer, got True" + }, + { + "name": "version-float", + "why": "a float is not an integer", + "section": "version", + "document": { + "ownir_version": 0.0 + }, + "raw": false, + "verdict": "reject", + "category": "version", + "message": "OwnIR 'ownir_version' must be an integer, got 0.0" + }, + { + "name": "version-null", + "why": "explicit null is NOT the same as absent — absent defaults to current, null is a stated non-integer", + "section": "version", + "document": { + "ownir_version": null + }, + "raw": false, + "verdict": "reject", + "category": "version", + "message": "OwnIR 'ownir_version' must be an integer, got None" + }, + { + "name": "components-object", + "why": "`components` must be an array", + "section": "components", + "document": { + "ownir_version": 0, + "components": { + "a": 1 + } + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'components' must be a JSON array of objects" + }, + { + "name": "components-of-scalars", + "why": "an array, but not of objects", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + 1, + 2 + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'components' must be a JSON array of objects" + }, + { + "name": "subscriptions-not-array", + "why": "each component's `subscriptions` must be an array of objects", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": 7 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "each component's 'subscriptions' must be objects" + }, + { + "name": "subscriptions-of-scalars", + "why": "…of OBJECTS, not scalars", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + "x" + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "each component's 'subscriptions' must be objects" + }, + { + "name": "resource-not-string", + "why": "`resource` must be a string before its value can be checked", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "resource": 7 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "subscription 'resource' must be a string, got 7" + }, + { + "name": "resource-unknown", + "why": "IR4: a present-but-unknown kind changes routing, so it is rejected at the door rather than mis-routed. A new kind must bump OWNIR_VERSION", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "resource": "bogus" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "unknown resource kind 'bogus' — a new kind is a vocabulary change that must bump OWNIR_VERSION (see spec/OwnIR.md §2)" + }, + { + "name": "resource-empty-string", + "why": "the empty string is present-but-unknown, not absent", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "resource": "" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "unknown resource kind '' — a new kind is a vocabulary change that must bump OWNIR_VERSION (see spec/OwnIR.md §2)" + }, + { + "name": "subscription-type-not-string", + "why": "optional `type`, present ⇒ string", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "type": 7 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "subscription 'type' must be a string, got 7" + }, + { + "name": "subscription-source-type-not-string", + "why": "optional `source_type`, present ⇒ string", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "source_type": 7 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "subscription 'source_type' must be a string, got 7" + }, + { + "name": "subscription-source-provenance-not-string", + "why": "optional `source_provenance`, present ⇒ string", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "source_provenance": 7 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "subscription 'source_provenance' must be a string, got 7" + }, + { + "name": "subscription-ignore-reason-not-string", + "why": "optional `ignore_reason`, present ⇒ string", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "ignore_reason": 7 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "subscription 'ignore_reason' must be a string, got 7" + }, + { + "name": "column-zero", + "why": "#317: a column is 1-based or absent. `0` is a producer bug, and reading it as 'unknown' would hide the bug while looking correct", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "column": 0 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "subscription 'column' must be a 1-based integer or absent, got 0" + }, + { + "name": "column-negative", + "why": "…and so is a negative column", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "column": -1 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "subscription 'column' must be a 1-based integer or absent, got -1" + }, + { + "name": "column-bool", + "why": "the bool-is-int trap again: `True` would otherwise be accepted as column 1 — a fabricated coordinate", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "column": true + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "subscription 'column' must be a 1-based integer or absent, got True" + }, + { + "name": "column-string", + "why": "a string column is not a coordinate", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "column": "3" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "subscription 'column' must be a 1-based integer or absent, got '3'" + }, + { + "name": "services-not-array", + "why": "`services` must be an array of objects", + "section": "services", + "document": { + "ownir_version": 0, + "services": { + "a": 1 + } + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'services' must be a JSON array of objects" + }, + { + "name": "service-lifetime-unknown", + "why": "the DI lifetime is a closed set", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "eternal" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "service 'lifetime' must be one of ['scoped', 'singleton', 'transient'], got 'eternal'" + }, + { + "name": "service-name-empty", + "why": "the service name is the identity the DI graph joins on", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "", + "lifetime": "singleton" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "service 'name' must be a non-empty string" + }, + { + "name": "service-name-not-string", + "why": "…and it must be a string", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": 7, + "lifetime": "singleton" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "service 'name' must be a non-empty string" + }, + { + "name": "service-deps-not-array", + "why": "`deps` is an array of strings", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "deps": "a" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'deps' must be an array of strings" + }, + { + "name": "service-deps-of-ints", + "why": "…of STRINGS", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "deps": [ + 1 + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'deps' must be an array of strings" + }, + { + "name": "service-weak-deps-of-ints", + "why": "same for `weak_deps`", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "weak_deps": [ + 1 + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'weak_deps' must be an array of strings" + }, + { + "name": "service-root-resolves-of-ints", + "why": "same for `root_resolves`", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "root_resolves": [ + 1 + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'root_resolves' must be an array of strings" + }, + { + "name": "service-scope-cached-of-ints", + "why": "same for `scope_cached`", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "scope_cached": [ + 1 + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'scope_cached' must be an array of strings" + }, + { + "name": "service-file-not-string", + "why": "`file` is a string", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "file": 7 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'file' must be a string" + }, + { + "name": "service-line-not-int", + "why": "`line` is an integer", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "line": "3" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'line' must be an integer" + }, + { + "name": "service-line-bool", + "why": "…and a bool is not an integer", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "line": true + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'line' must be an integer" + }, + { + "name": "service-ctor-file-not-string", + "why": "`ctor_file` is a string", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "ctor_file": 7 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'ctor_file' must be a string" + }, + { + "name": "service-ctor-line-not-int", + "why": "`ctor_line` is an integer", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "ctor_line": "3" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'ctor_line' must be an integer" + }, + { + "name": "service-ctor-type-not-string", + "why": "`ctor_type` is a string", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "ctor_type": 7 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'ctor_type' must be a string" + }, + { + "name": "service-root-resolve-sites-not-objects", + "why": "`root_resolve_sites` is an array of {type,file,line} objects", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "root_resolve_sites": [ + "x" + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'root_resolve_sites' must be an array of {type:str, file:str, line:int} objects" + }, + { + "name": "service-scope-cache-sites-not-objects", + "why": "same for `scope_cache_sites`", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "scope_cache_sites": [ + "x" + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'scope_cache_sites' must be an array of {type:str, file:str, line:int} objects" + }, + { + "name": "effects-not-array", + "why": "`effects` must be an array of objects", + "section": "effects", + "document": { + "ownir_version": 0, + "effects": { + "a": 1 + } + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'effects' must be a JSON array of objects" + }, + { + "name": "effect-deps-of-ints", + "why": "`deps` is an array of strings", + "section": "effects", + "document": { + "ownir_version": 0, + "effects": [ + { + "deps": [ + 1 + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "effect 'deps' must be an array of strings" + }, + { + "name": "effect-io-not-bool", + "why": "`io` is a boolean", + "section": "effects", + "document": { + "ownir_version": 0, + "effects": [ + { + "io": "yes" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "effect 'io' must be a boolean, got 'yes'" + }, + { + "name": "effect-line-not-int", + "why": "`line` is an integer", + "section": "effects", + "document": { + "ownir_version": 0, + "effects": [ + { + "line": "3" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "effect 'line' must be an integer" + }, + { + "name": "effect-bindings-not-objects", + "why": "`bindings` is an array of objects", + "section": "effects", + "document": { + "ownir_version": 0, + "effects": [ + { + "bindings": [ + "x" + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "effect 'bindings' must be a JSON array of objects" + }, + { + "name": "binding-name-not-string", + "why": "binding `name` is a string", + "section": "effects", + "document": { + "ownir_version": 0, + "effects": [ + { + "bindings": [ + { + "name": 7 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "binding 'name' must be a string" + }, + { + "name": "binding-init-not-string", + "why": "binding `init` is a string", + "section": "effects", + "document": { + "ownir_version": 0, + "effects": [ + { + "bindings": [ + { + "init": 7 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "binding 'init' must be a string" + }, + { + "name": "binding-refs-of-ints", + "why": "binding `refs` is string array", + "section": "effects", + "document": { + "ownir_version": 0, + "effects": [ + { + "bindings": [ + { + "refs": [ + 1 + ] + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "binding 'refs' must be an array of strings" + }, + { + "name": "binding-line-not-int", + "why": "binding `line` is an integer", + "section": "effects", + "document": { + "ownir_version": 0, + "effects": [ + { + "bindings": [ + { + "line": "3" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "binding 'line' must be an integer" + }, + { + "name": "functions-not-array", + "why": "`functions` must be an array of objects", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": { + "a": 1 + } + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'functions' must be a JSON array of objects" + }, + { + "name": "function-sig-not-string", + "why": "`sig` present ⇒ string. It is the overload key MOS resolution joins on, so a non-string is not a cosmetic problem", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "sig": 7 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "function 'sig' must be a string, got 7" + }, + { + "name": "params-not-array", + "why": "`params` is an array of objects", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": "x" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "a function's 'params' must be a JSON array of objects" + }, + { + "name": "params-of-scalars", + "why": "…of OBJECTS", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": [ + "x" + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "a function's 'params' must be a JSON array of objects" + }, + { + "name": "param-name-empty", + "why": "the parameter name is the identity effects attach to", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": [ + { + "name": "" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "parameter 'name' must be a non-empty string, got ''" + }, + { + "name": "param-name-not-string", + "why": "…and it must be a string", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": [ + { + "name": 7 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "parameter 'name' must be a non-empty string, got 7" + }, + { + "name": "param-line-not-int", + "why": "param `line` is an integer", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": [ + { + "name": "p", + "line": "3" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "parameter 'line' must be an integer, got '3'" + }, + { + "name": "param-effect-unknown", + "why": "the parameter effect is a closed set", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": [ + { + "name": "p", + "effect": "teleport" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "parameter 'effect' must be one of ['borrow', 'borrow_mut', 'consume', 'plain'], got 'teleport'" + }, + { + "name": "protocols-not-array", + "why": "`protocols` must be an array of objects", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": { + "a": 1 + } + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'protocols' must be a JSON array of objects" + }, + { + "name": "protocol-duplicate-name", + "why": "the protocol name is the identity verdicts map back by, so a duplicate is an identity collision, not a harmless repeat. Both records are individually VALID — the older version of this control used records with no `opens`/`closes`, which the reference refused for record shape long before it compared any names", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + } + }, + { + "name": "P", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + } + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "duplicate protocol name 'P' — protocol names are the identity findings map back by and must be unique" + }, + { + "name": "protocol-functions-not-array", + "why": "`protocol_functions` must be an array of objects", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocol_functions": { + "a": 1 + } + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'protocol_functions' must be a JSON array of objects" + }, + { + "name": "order-version-before-components", + "why": "both the version and `components` are wrong; the VERSION gate runs first, because a vocabulary mismatch makes every later shape check meaningless", + "section": "order", + "document": { + "ownir_version": 99, + "components": { + "a": 1 + } + }, + "raw": false, + "verdict": "reject", + "category": "version", + "message": "OwnIR facts are schema v99, but this core understands v0. Build the Roslyn extractor and the Python core from the same commit — the OwnIR fact vocabulary changed between the version that produced this file and the one reading it." + }, + { + "name": "order-version-before-resource", + "why": "version wins over an unknown resource kind for the same reason", + "section": "order", + "document": { + "ownir_version": 99, + "components": [ + { + "subscriptions": [ + { + "resource": "bogus" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "version", + "message": "OwnIR facts are schema v99, but this core understands v0. Build the Roslyn extractor and the Python core from the same commit — the OwnIR fact vocabulary changed between the version that produced this file and the one reading it." + }, + { + "name": "order-root-before-version", + "why": "root-is-object precedes the version gate — there is nowhere to read `ownir_version` from until the root is an object", + "section": "order", + "document": [ + { + "ownir_version": 99 + } + ], + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR root must be a JSON object" + }, + { + "name": "order-json-before-everything", + "why": "an unparseable document cannot reach any structural check", + "section": "order", + "document": "{\"ownir_version\": 99", + "raw": true, + "verdict": "reject", + "category": "json", + "message": " is not valid JSON: Expecting ',' delimiter: line 1 column 21 (char 20)" + }, + { + "name": "order-resource-shape-before-vocabulary", + "why": "`resource` must be a STRING before its value can be tested against the closed set — a shape failure, not a vocabulary one", + "section": "order", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "resource": 7, + "column": 0 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "subscription 'resource' must be a string, got 7" + }, + { + "name": "order-components-before-services", + "why": "sections are validated in declaration order: `components` before `services`, so a document breaking both reports components", + "section": "order", + "document": { + "ownir_version": 0, + "components": { + "a": 1 + }, + "services": { + "b": 2 + } + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'components' must be a JSON array of objects" + }, + { + "name": "service-lifetime-absent", + "why": "there is no default lifetime: an absent one is `None`, which is outside the closed set exactly like a misspelt one. This is the case `_svc()` could not express, and the port accepted it", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "service 'lifetime' must be one of ['scoped', 'singleton', 'transient'], got None" + }, + { + "name": "service-empty-record", + "why": "a service record with nothing in it: the lifetime gate fires first, so this is a vocabulary failure and not a missing-name one", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + {} + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "service 'lifetime' must be one of ['scoped', 'singleton', 'transient'], got None" + }, + { + "name": "service-lifetime-null", + "why": "an explicit null is the same as absent here — both are `None`, and neither is in the closed set", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": null + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "service 'lifetime' must be one of ['scoped', 'singleton', 'transient'], got None" + }, + { + "name": "service-name-absent", + "why": "with a valid lifetime, an absent name reaches the identity check", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "lifetime": "singleton" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "service 'name' must be a non-empty string" + }, + { + "name": "order-lifetime-before-name", + "why": "the reference checks `lifetime` BEFORE `name`, so a record that breaks both is a vocabulary failure. A port that validates identity first reports `identity` and is wrong about which rule fired", + "section": "order", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "", + "lifetime": "eternal" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "service 'lifetime' must be one of ['scoped', 'singleton', 'transient'], got 'eternal'" + }, + { + "name": "order-name-before-deps", + "why": "…and `name` before the remaining service fields", + "section": "order", + "document": { + "ownir_version": 0, + "services": [ + { + "lifetime": "singleton", + "name": "", + "deps": 7 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "service 'name' must be a non-empty string" + }, + { + "name": "order-resource-vocabulary-before-column", + "why": "within one subscription, the resource vocabulary precedes the column contract", + "section": "order", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "resource": "bogus", + "column": 0 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "unknown resource kind 'bogus' — a new kind is a vocabulary change that must bump OWNIR_VERSION (see spec/OwnIR.md §2)" + }, + { + "name": "order-column-before-subscription-type", + "why": "…and the column precedes the optional `type`", + "section": "order", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "column": 0, + "type": 7 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "subscription 'column' must be a 1-based integer or absent, got 0" + }, + { + "name": "order-sig-before-body-column", + "why": "inside a function, `sig` is checked before the body's columns", + "section": "order", + "document": { + "ownir_version": 0, + "functions": [ + { + "sig": 7, + "body": [ + { + "op": "x", + "column": 0 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "function 'sig' must be a string, got 7" + }, + { + "name": "order-body-column-before-param-name", + "why": "and the BODY's columns are checked before `params` — the least obvious edge in the whole door, because params read like the more primitive thing", + "section": "order", + "document": { + "ownir_version": 0, + "functions": [ + { + "body": [ + { + "op": "x", + "column": 0 + } + ], + "params": [ + { + "name": "" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "function body op 'x' 'column' must be a 1-based integer or absent, got 0" + }, + { + "name": "order-param-name-before-param-line", + "why": "within a param, identity precedes the line", + "section": "order", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": [ + { + "name": "", + "line": "3" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "parameter 'name' must be a non-empty string, got ''" + }, + { + "name": "order-param-line-before-param-column", + "why": "…the line precedes the column", + "section": "order", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": [ + { + "name": "p", + "line": "3", + "column": 0 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "parameter 'line' must be an integer, got '3'" + }, + { + "name": "order-param-column-before-effect", + "why": "…and the column precedes the effect vocabulary", + "section": "order", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": [ + { + "name": "p", + "column": 0, + "effect": "teleport" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "parameter 'column' must be a 1-based integer or absent, got 0" + }, + { + "name": "order-components-shape-before-services-vocabulary", + "why": "a components SHAPE failure outranks a services VOCABULARY failure, because components is validated first — completely. A door that runs all semantic gates before any shape check reports the vocabulary error and inverts the contract", + "section": "order", + "document": { + "ownir_version": 0, + "components": { + "a": 1 + }, + "services": [ + { + "lifetime": "bad" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'components' must be a JSON array of objects" + }, + { + "name": "order-components-location-before-services-vocabulary", + "why": "same precedence with a column failure standing in for the shape one", + "section": "order", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "column": 0 + } + ] + } + ], + "services": [ + { + "lifetime": "bad" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "subscription 'column' must be a 1-based integer or absent, got 0" + }, + { + "name": "order-services-vocabulary-before-effects-shape", + "why": "and the same one section later: services before effects", + "section": "order", + "document": { + "ownir_version": 0, + "services": [ + { + "lifetime": "bad" + } + ], + "effects": { + "a": 1 + } + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "service 'lifetime' must be one of ['scoped', 'singleton', 'transient'], got 'bad'" + }, + { + "name": "order-effects-shape-before-functions-identity", + "why": "effects before functions", + "section": "order", + "document": { + "ownir_version": 0, + "effects": { + "a": 1 + }, + "functions": [ + { + "params": [ + { + "name": "" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'effects' must be a JSON array of objects" + }, + { + "name": "order-functions-identity-before-protocols-shape", + "why": "functions before protocols", + "section": "order", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": [ + { + "name": "" + } + ] + } + ], + "protocols": 7 + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "parameter 'name' must be a non-empty string, got ''" + }, + { + "name": "order-protocols-identity-before-protocol-functions-shape", + "why": "protocols before protocol_functions — the last edge in the chain", + "section": "order", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "" + } + ], + "protocol_functions": 7 + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "protocol: 'name' must be a non-empty string, got ''" + }, + { + "name": "accept-flow-column-one", + "why": "column 1 on a flow op, nested and not — the acceptance twin the rejections below need", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "body": [ + { + "op": "acquire", + "column": 1 + }, + { + "op": "if", + "then": [ + { + "op": "release", + "column": 1 + } + ], + "else": [ + { + "op": "call", + "column": 1 + } + ] + }, + { + "op": "while", + "body": [ + { + "op": "acquire", + "column": 1 + } + ] + } + ] + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-flow-body-not-array", + "why": "a non-array `body` is SKIPPED, not rejected: the reference returns early rather than raising. Pinned because it is the kind of tolerance a port silently tightens", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "body": 7 + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-flow-body-of-scalars", + "why": "…and a non-object op inside the body is skipped the same way", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "body": [ + 1, + "x", + null + ] + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "flow-column-zero", + "why": "the 1-based contract reaches flow ops, not just subscriptions", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "body": [ + { + "op": "acquire", + "column": 0 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "function body op 'acquire' 'column' must be a 1-based integer or absent, got 0" + }, + { + "name": "flow-column-negative", + "why": "…negative likewise", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "body": [ + { + "op": "acquire", + "column": -2 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "function body op 'acquire' 'column' must be a 1-based integer or absent, got -2" + }, + { + "name": "flow-column-bool", + "why": "…and the bool-is-int trap", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "body": [ + { + "op": "acquire", + "column": true + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "function body op 'acquire' 'column' must be a 1-based integer or absent, got True" + }, + { + "name": "flow-column-in-if-then", + "why": "recursion into `then` — a hoisted branch acquire", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "body": [ + { + "op": "if", + "then": [ + { + "op": "acquire", + "column": 0 + } + ] + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "function body op 'acquire' 'column' must be a 1-based integer or absent, got 0" + }, + { + "name": "flow-column-in-if-else", + "why": "…into `else`", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "body": [ + { + "op": "if", + "else": [ + { + "op": "acquire", + "column": 0 + } + ] + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "function body op 'acquire' 'column' must be a 1-based integer or absent, got 0" + }, + { + "name": "flow-column-in-while-body", + "why": "…into a loop `body`", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "body": [ + { + "op": "while", + "body": [ + { + "op": "acquire", + "column": 0 + } + ] + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "function body op 'acquire' 'column' must be a 1-based integer or absent, got 0" + }, + { + "name": "flow-column-deeply-nested", + "why": "…and through three levels, so a one-level-deep port fails here", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "body": [ + { + "op": "if", + "then": [ + { + "op": "while", + "body": [ + { + "op": "if", + "else": [ + { + "op": "acquire", + "column": 0 + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "function body op 'acquire' 'column' must be a 1-based integer or absent, got 0" + }, + { + "name": "accept-param-column-one", + "why": "the 1-based boundary on a parameter coordinate", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": [ + { + "name": "p", + "column": 1 + } + ] + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "param-column-zero", + "why": "the same contract on `params[].column` — a separate call site in the reference, and one the port did not have at all", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": [ + { + "name": "p", + "column": 0 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "parameter 'column' must be a 1-based integer or absent, got 0" + }, + { + "name": "param-column-negative", + "why": "…negative likewise", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": [ + { + "name": "p", + "column": -1 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "parameter 'column' must be a 1-based integer or absent, got -1" + }, + { + "name": "param-column-bool", + "why": "…and the bool-is-int trap", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": [ + { + "name": "p", + "column": true + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "parameter 'column' must be a 1-based integer or absent, got True" + }, + { + "name": "param-column-string", + "why": "…and a string is no coordinate", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": [ + { + "name": "p", + "column": "3" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "parameter 'column' must be a 1-based integer or absent, got '3'" + }, + { + "name": "accept-protocol-minimal", + "why": "a name plus an opens/closes pair is a complete protocol", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + } + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-protocol-full", + "why": "every optional key at once: barriers, allow with narrowed args, an explicit scope, a description, and `exit_barriers` false — legal only BECAUSE a barrier is present", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + }, + "barriers": [ + { + "kind": "call", + "callee": "Barrier" + } + ], + "allow": [ + { + "kind": "call", + "callee": "Allowed", + "args": [ + "x" + ] + } + ], + "exit_barriers": false, + "scope": { + "methods": [ + "Type.Method" + ] + }, + "description": "d" + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-protocol-call-matchers", + "why": "the other matcher kind: `call`, with and without narrowed args", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "call", + "callee": "Begin" + }, + "closes": { + "kind": "call", + "callee": "End", + "args": [ + "a", + "b" + ] + } + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-two-distinct-protocols", + "why": "two protocols with DIFFERENT names — the twin that makes the duplicate-name rejection discriminating rather than a test of 'more than one protocol is refused'", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "A", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + } + }, + { + "name": "B", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + } + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "protocol-not-object", + "why": "`protocols` is checked only for LIST-ness at the top; a scalar entry is refused by the record parser", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + 7 + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "a protocol must be an object, got 7" + }, + { + "name": "protocol-null-entry", + "why": "…and null is not a record", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + null + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "a protocol must be an object, got None" + }, + { + "name": "protocol-name-absent", + "why": "the protocol name is required, not defaulted", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "opens": {}, + "closes": {} + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "protocol: 'name' must be a non-empty string, got None" + }, + { + "name": "protocol-name-empty", + "why": "…and must be non-empty", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + } + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "protocol: 'name' must be a non-empty string, got ''" + }, + { + "name": "protocol-name-not-string", + "why": "…and a string", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": 7, + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + } + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "protocol: 'name' must be a non-empty string, got 7" + }, + { + "name": "protocol-missing-opens", + "why": "a protocol without `opens` cannot state what it is tracking", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "closes": { + "kind": "call", + "callee": "E" + } + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol 'P': 'opens' and 'closes' are both required" + }, + { + "name": "protocol-missing-closes", + "why": "…and likewise `closes`", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "call", + "callee": "B" + } + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol 'P': 'opens' and 'closes' are both required" + }, + { + "name": "protocol-opens-not-object", + "why": "a matcher is an object", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": 7, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + } + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol 'P' 'opens' must be an object, got 7" + }, + { + "name": "protocol-matcher-unknown-kind", + "why": "the matcher kind is a closed vocabulary (assign | call), fail-loud like a flow op", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "teleport", + "target": "f" + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + } + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "protocol 'P' 'opens': unknown matcher kind 'teleport' — the vocabulary is ['assign', 'call'] (spec/OwnIR.md §8)" + }, + { + "name": "protocol-matcher-kind-absent", + "why": "…and absent is outside that set too", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "target": "f" + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + } + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "protocol 'P' 'opens': unknown matcher kind None — the vocabulary is ['assign', 'call'] (spec/OwnIR.md §8)" + }, + { + "name": "protocol-assign-matcher-target-empty", + "why": "the assign target is the member name the rule joins events by", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "assign", + "target": "", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + } + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "protocol 'P' 'opens': 'target' must be a non-empty string, got ''" + }, + { + "name": "protocol-assign-matcher-without-value", + "why": "an opens/closes assign matcher must state the written boolean — 'any write opens' is not a checkable protocol", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "assign", + "target": "f" + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + } + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol 'P' 'opens': an opens/closes assign matcher must state the written boolean 'value' — 'any write' cannot open or close an obligation" + }, + { + "name": "protocol-assign-matcher-value-not-bool", + "why": "…and that value is a boolean", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "assign", + "target": "f", + "value": "yes" + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + } + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol 'P' 'opens': assign 'value' must be a boolean, got 'yes'" + }, + { + "name": "protocol-call-matcher-callee-empty", + "why": "the callee is the name a call event joins by", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "call", + "callee": "" + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + } + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "protocol 'P' 'opens': 'callee' must be a non-empty string, got ''" + }, + { + "name": "protocol-call-matcher-args-not-strings", + "why": "narrowed args are strings", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "call", + "callee": "C", + "args": [ + 1 + ] + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + } + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol 'P' 'opens': call 'args' must be an array of strings" + }, + { + "name": "protocol-barriers-not-array", + "why": "`barriers` is an array", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + }, + "barriers": 7 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol 'P': 'barriers' must be an array" + }, + { + "name": "protocol-allow-not-array", + "why": "`allow` is an array", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + }, + "allow": 7 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol 'P': 'allow' must be an array" + }, + { + "name": "protocol-exit-barriers-not-bool", + "why": "`exit_barriers` is a boolean", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + }, + "exit_barriers": "no" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol 'P': 'exit_barriers' must be a boolean" + }, + { + "name": "protocol-never-fires", + "why": "no barriers AND no exit barriers: the rule can structurally never fire, which the reference refuses as decoration. Every value here is correctly typed and in vocabulary — what fails is meaning, so the category is `well_formedness`", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + }, + "exit_barriers": false + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "well_formedness", + "message": "protocol 'P': no barriers and exit_barriers is false — the protocol can never fire (a rule that structurally never fires is decoration)" + }, + { + "name": "protocol-barrier-equals-opens", + "why": "a barrier identical to `opens` is dead — the walk checks opens first, so the barrier can never fire. Same category, different mechanism: the record is well-typed and means nothing", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + }, + "barriers": [ + { + "kind": "assign", + "target": "flag", + "value": true + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "well_formedness", + "message": "protocol 'P': a barrier equals the 'opens' matcher — the open wins and the barrier can never fire (re-entrancy checks are not supported yet)" + }, + { + "name": "accept-protocol-fires-via-barrier", + "why": "`exit_barriers: false` is legal WITH a barrier — the twin that makes `protocol-never-fires` a rejection about meaning rather than a rejection of the field", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + }, + "exit_barriers": false, + "barriers": [ + { + "kind": "call", + "callee": "Barrier" + } + ] + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-protocol-barrier-differs-from-opens", + "why": "…and a barrier that is not `opens` is legal, which is what makes the equality the defect rather than the presence", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + }, + "barriers": [ + { + "kind": "assign", + "target": "flag", + "value": false + } + ] + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "protocol-scope-not-object", + "why": "`scope` is an object", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + }, + "scope": 7 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol 'P': 'scope' must be an object" + }, + { + "name": "protocol-scope-methods-not-array", + "why": "`scope.methods` is an array — a CONTAINER failure", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + }, + "scope": { + "methods": 7 + } + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol 'P': 'scope.methods' must be an array of non-empty strings" + }, + { + "name": "protocol-scope-methods-empty-entry", + "why": "…of non-empty method names. The reference raises one message for this and the case above; the ledger separates them because a missing container and an unusable name are different defects", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + }, + "scope": { + "methods": [ + "" + ] + } + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "protocol 'P': 'scope.methods' must be an array of non-empty strings" + }, + { + "name": "protocol-description-not-string", + "why": "`description` is a string", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + }, + "description": 7 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol 'P': 'description' must be a string" + }, + { + "name": "order-protocol-name-before-opens", + "why": "the name is read before `opens`/`closes` are required, so a record missing everything reports identity", + "section": "order", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "protocol: 'name' must be a non-empty string, got ''" + }, + { + "name": "order-protocol-opens-before-closes", + "why": "`opens` is parsed before `closes`", + "section": "order", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "opens": 7, + "closes": { + "kind": "teleport" + } + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol 'P' 'opens' must be an object, got 7" + }, + { + "name": "accept-protocol-function-minimal", + "why": "`events` defaults to empty, so a name alone is a valid record", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M" + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-protocol-function-every-event-kind", + "why": "all six event kinds including the two recursive ones — the acceptance side of the closed `ev` vocabulary", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "events": [ + { + "ev": "assign", + "target": "f", + "value": true, + "line": 1 + }, + { + "ev": "assign", + "target": "f", + "line": 2 + }, + { + "ev": "call", + "callee": "C", + "arg": "x", + "line": 3 + }, + { + "ev": "call", + "callee": "D", + "line": 4 + }, + { + "ev": "return", + "line": 5 + }, + { + "ev": "throw", + "line": 6 + }, + { + "ev": "if", + "line": 7, + "then": [ + { + "ev": "return", + "line": 8 + } + ], + "else": [ + { + "ev": "throw", + "line": 9 + } + ] + }, + { + "ev": "while", + "line": 10, + "body": [ + { + "ev": "call", + "callee": "E", + "line": 11 + } + ] + } + ] + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "protocol-function-not-object", + "why": "like `protocols`, only list-ness is checked at the top", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + 7 + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "a protocol function must be an object, got 7" + }, + { + "name": "protocol-function-null-entry", + "why": "…and null is not a record", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + null + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "a protocol function must be an object, got None" + }, + { + "name": "protocol-function-name-empty", + "why": "the method name is what a protocol's scope matches against", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "protocol function: 'name' must be a non-empty string, got ''" + }, + { + "name": "protocol-function-name-absent", + "why": "…and it is required", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "file": "a.cs" + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "protocol function: 'name' must be a non-empty string, got None" + }, + { + "name": "protocol-function-file-not-string", + "why": "`file` is optional but, present, a string", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "file": 7 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol function 'M': 'file' must be a string" + }, + { + "name": "protocol-function-events-not-array", + "why": "`events` is an ordered array", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "events": 7 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol function 'M': events must be an array, got 7" + }, + { + "name": "protocol-function-event-not-object", + "why": "…of objects", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "events": [ + 7 + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol function 'M': each event must be an object, got 7" + }, + { + "name": "protocol-function-event-unknown-kind", + "why": "the `ev` discriminator is closed (IR4): a present-but-unknown value is rejected, never skipped", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "events": [ + { + "ev": "teleport" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "protocol function 'M': unknown protocol event 'teleport' — the vocabulary is ['assign', 'call', 'if', 'return', 'throw', 'while'] (spec/OwnIR.md §8)" + }, + { + "name": "protocol-function-event-kind-absent", + "why": "…and an absent one is outside the set too", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "events": [ + { + "line": 1 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "protocol function 'M': unknown protocol event None — the vocabulary is ['assign', 'call', 'if', 'return', 'throw', 'while'] (spec/OwnIR.md §8)" + }, + { + "name": "protocol-function-event-line-not-int", + "why": "an event line is an integer", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "events": [ + { + "ev": "return", + "line": "3" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol function 'M': 'line' must be an integer, got '3'" + }, + { + "name": "protocol-function-event-line-bool", + "why": "…and the bool-is-int trap reaches here as well", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "events": [ + { + "ev": "return", + "line": true + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol function 'M': 'line' must be an integer, got True" + }, + { + "name": "protocol-function-assign-target-empty", + "why": "an assign event's target is a name slot", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "events": [ + { + "ev": "assign", + "target": "" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "protocol function 'M' assign: 'target' must be a non-empty string, got ''" + }, + { + "name": "protocol-function-assign-value-not-bool", + "why": "…its value is a boolean or absent (absent = an opaque write)", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "events": [ + { + "ev": "assign", + "target": "f", + "value": "yes" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol function 'M': assign 'value' must be a boolean or absent (absent = opaque write), got 'yes'" + }, + { + "name": "protocol-function-call-callee-empty", + "why": "a call event's callee is a name slot", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "events": [ + { + "ev": "call", + "callee": "" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "protocol function 'M' call: 'callee' must be a non-empty string, got ''" + }, + { + "name": "protocol-function-call-arg-not-string", + "why": "…its distinguished arg is a string or absent", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "events": [ + { + "ev": "call", + "callee": "C", + "arg": 7 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol function 'M': call 'arg' must be a string or absent, got 7" + }, + { + "name": "protocol-function-event-in-if-then", + "why": "the event vocabulary is enforced recursively — inside `then`", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "events": [ + { + "ev": "if", + "then": [ + { + "ev": "teleport" + } + ] + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "protocol function 'M': unknown protocol event 'teleport' — the vocabulary is ['assign', 'call', 'if', 'return', 'throw', 'while'] (spec/OwnIR.md §8)" + }, + { + "name": "protocol-function-event-in-if-else", + "why": "…inside `else`", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "events": [ + { + "ev": "if", + "else": [ + { + "ev": "teleport" + } + ] + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "protocol function 'M': unknown protocol event 'teleport' — the vocabulary is ['assign', 'call', 'if', 'return', 'throw', 'while'] (spec/OwnIR.md §8)" + }, + { + "name": "protocol-function-event-in-while-body", + "why": "…and inside a loop body", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "events": [ + { + "ev": "while", + "body": [ + { + "ev": "teleport" + } + ] + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "protocol function 'M': unknown protocol event 'teleport' — the vocabulary is ['assign', 'call', 'if', 'return', 'throw', 'while'] (spec/OwnIR.md §8)" + }, + { + "name": "protocol-function-event-deeply-nested", + "why": "…through three levels, so a one-level-deep port fails here", + "section": "protocol_functions", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "events": [ + { + "ev": "if", + "then": [ + { + "ev": "while", + "body": [ + { + "ev": "assign", + "target": "" + } + ] + } + ] + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "identity", + "message": "protocol function 'M' assign: 'target' must be a non-empty string, got ''" + }, + { + "name": "order-event-kind-before-line", + "why": "the `ev` vocabulary is tested before the line", + "section": "order", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "events": [ + { + "ev": "teleport", + "line": "x" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "vocabulary", + "message": "protocol function 'M': unknown protocol event 'teleport' — the vocabulary is ['assign', 'call', 'if', 'return', 'throw', 'while'] (spec/OwnIR.md §8)" + }, + { + "name": "order-event-line-before-assign-target", + "why": "…and the line before the per-kind fields", + "section": "order", + "document": { + "ownir_version": 0, + "protocol_functions": [ + { + "name": "M", + "events": [ + { + "ev": "assign", + "line": "x", + "target": "" + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "protocol function 'M': 'line' must be an integer, got 'x'" + }, + { + "name": "components-null", + "why": "a present null is not an absent section", + "section": "root", + "document": { + "ownir_version": 0, + "components": null + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'components' must be a JSON array of objects" + }, + { + "name": "services-null", + "why": "…same for services", + "section": "root", + "document": { + "ownir_version": 0, + "services": null + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'services' must be a JSON array of objects" + }, + { + "name": "effects-null", + "why": "…effects", + "section": "root", + "document": { + "ownir_version": 0, + "effects": null + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'effects' must be a JSON array of objects" + }, + { + "name": "functions-null", + "why": "…functions", + "section": "root", + "document": { + "ownir_version": 0, + "functions": null + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'functions' must be a JSON array of objects" + }, + { + "name": "protocols-null", + "why": "…protocols", + "section": "root", + "document": { + "ownir_version": 0, + "protocols": null + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'protocols' must be a JSON array of objects" + }, + { + "name": "protocol-functions-null", + "why": "…and protocol_functions", + "section": "root", + "document": { + "ownir_version": 0, + "protocol_functions": null + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'protocol_functions' must be a JSON array of objects" + }, + { + "name": "subscriptions-null", + "why": "…and one level down", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": null + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "each component's 'subscriptions' must be objects" + }, + { + "name": "params-null", + "why": "…likewise params", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": null + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "a function's 'params' must be a JSON array of objects" + }, + { + "name": "service-deps-null", + "why": "…and a null string array", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "deps": null + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'deps' must be an array of strings" + }, + { + "name": "effect-bindings-null", + "why": "…and null bindings", + "section": "effects", + "document": { + "ownir_version": 0, + "effects": [ + { + "bindings": null + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "effect 'bindings' must be a JSON array of objects" + }, + { + "name": "service-line-null", + "why": "a null scalar is not an absent scalar either", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "line": null + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'line' must be an integer" + }, + { + "name": "service-file-null", + "why": "…same for a string scalar", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "file": null + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'file' must be a string" + }, + { + "name": "subscription-resource-null", + "why": "…and `resource` null fails the string check before the vocabulary", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "resource": null + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "subscription 'resource' must be a string, got None" + }, + { + "name": "accept-column-null", + "why": "the column is the exception: the reference returns early on None, so an explicit null IS accepted where a null line is not", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "column": null + } + ] + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-param-effect-null", + "why": "…and a null effect is read as absent, not as an unknown value", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "params": [ + { + "name": "p", + "effect": null + } + ] + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-function-sig-null", + "why": "…and a null `sig` is accepted, because the check is `is not None`", + "section": "functions", + "document": { + "ownir_version": 0, + "functions": [ + { + "sig": null + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-service-sites-full", + "why": "well-formed DI004/DI005 call-site metadata", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "root_resolve_sites": [ + { + "type": "T", + "file": "a.cs", + "line": 3 + } + ], + "scope_cache_sites": [ + { + "type": "U", + "file": "b.cs", + "line": 4 + } + ] + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-service-site-empty-object", + "why": "every site field is defaulted, so `{}` is a legal site", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "root_resolve_sites": [ + {} + ] + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "service-site-line-bool", + "why": "the bool-is-int trap inside a site record", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "root_resolve_sites": [ + { + "line": true + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'root_resolve_sites' must be an array of {type:str, file:str, line:int} objects" + }, + { + "name": "service-site-type-not-string", + "why": "…and a site `type` is a string", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "root_resolve_sites": [ + { + "type": 7 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'root_resolve_sites' must be an array of {type:str, file:str, line:int} objects" + }, + { + "name": "service-scope-cache-site-line-bool", + "why": "…both site arrays carry the same contract", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "scope_cache_sites": [ + { + "line": true + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'scope_cache_sites' must be an array of {type:str, file:str, line:int} objects" + }, + { + "name": "accept-line-at-i64-max", + "why": "the largest line both loaders accept", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "line": 9223372036854775807 + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-column-at-i64-max", + "why": "…and the largest column", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "column": 9223372036854775807 + } + ] + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-negative-line", + "why": "a NEGATIVE line is accepted: only columns carry the 1-based rule, and conflating the two would tighten the door", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "line": -5 + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-zero-line", + "why": "…and zero is the line default", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "line": 0 + } + ] + }, + "raw": false, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "column-float", + "why": "a float is not an integer coordinate even when it is whole", + "section": "components", + "document": { + "ownir_version": 0, + "components": [ + { + "subscriptions": [ + { + "column": 1.0 + } + ] + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "location", + "message": "subscription 'column' must be a 1-based integer or absent, got 1.0" + }, + { + "name": "service-line-float", + "why": "…and the same for a line, where it is a shape failure instead", + "section": "services", + "document": { + "ownir_version": 0, + "services": [ + { + "name": "S", + "lifetime": "singleton", + "line": 1.0 + } + ] + }, + "raw": false, + "verdict": "reject", + "category": "shape", + "message": "service 'line' must be an integer" + } + ] +} diff --git a/tests/test_ownir_validation_fixtures.py b/tests/test_ownir_validation_fixtures.py new file mode 100644 index 00000000..1441b548 --- /dev/null +++ b/tests/test_ownir_validation_fixtures.py @@ -0,0 +1,1183 @@ +#!/usr/bin/env python3 +"""OwnIR strict-door validation ledger (P-022 step 6b, #259 checkpoint 1). + +Freezes the **acceptance language** of `ownlang.ownir.load` — the BR-D1 strict +door — so the Rust `own_ir::OwnIr::from_json` can be shown to accept and reject +exactly the same documents, with zero Python at steady state. + +## Why a ledger and not a port of 47 `if`s + +Python's `load()` carries 47 `raise OwnIRError` sites; Rust carries three. That +gap is mostly illusory: Rust leans on serde's typing, which rejects a wrong +field type without an explicit check. So the checkpoint is not "transcribe 47 +branches" — it is **prove the two loaders accept the same language**, and fix +wherever they do not. A differential probe over 17 hand-picked controls already +found four Rust-only accepts, which is why this file sweeps the contract +instead of spot-checking it. + +## The first census was not a sweep, and said it was + +The first round of this ledger reached a 0/0/0 matrix over 77 controls and was +recorded as complete. Review then found seven divergences it could not see, and +the reason was structural rather than careless: **the same author wrote the +oracle and the port**, so one gap in reading BR-D1 produced a matching gap in +each, and the matrix agreed with itself. + +The sharpest instance: `_svc()` below always supplied `lifetime`, so no control +could ever omit it — and the reference rejects an absent `lifetime` while the +port accepted it. The ledger could not fail, which is a different property from +the port being right. + +Mutation testing proved the *implementation* against the *ledger*. Nothing +proved the ledger against the *contract*. So this round works the other way +round: every control below is derived by reading `load()` and the shared +obligation parser line by line, section by section, and each section's controls +are written before looking at what the port does with them. The helpers now +deliberately build *incomplete* records, because the missing-field case is the +one a convenience helper hides. + +## What "category" means per field, and why it is not the mechanism + +`identity` is every **name slot** — a value some other fact joins on: a service +name, a parameter name, a protocol name, a protocol function's name, a +matcher's `target`/`callee`, an event's `target`/`callee`, an entry of +`scope.methods`. Empty, mistyped, or duplicated, they are all the same defect: +the name cannot be used to join. `shape` is a wrong JSON type or container. + +That line is drawn on **what the field is**, not on which helper raised. Two +consequences worth stating, because both look like inconsistencies otherwise: + +* `scope.methods: 7` is `shape` and `scope.methods: [""]` is `identity`, even + though the reference raises one message for both. The category is a semantic + claim the ledger makes; the oracle only supplies accept/reject. +* A protocol that "can never fire" (no barriers with `exit_barriers: false`, or + a barrier equal to `opens`) is `well_formedness`, the seventh category. Every + value in such a record has the right type and a legal vocabulary; what is + broken is that the record cannot *mean* anything. + + An earlier revision filed both under `shape`, reasoning that the taxonomy was + already frozen at six. That was backwards. The taxonomy was frozen by the + FIRST census; this mechanism was found by the SECOND. A category set settled + by one census is a claim about that census, not about the contract — and + filing a newly discovered mechanism under the nearest existing name is the + exact substitution the taxonomy exists to prevent. Seven categories are not + worse than six; a false one is worse than both. + +## Integer width: measured, and deliberately not covered here + +Python integers are unbounded. Every `line`-like and `column`-like field in +`load()` accepts values far beyond 64 bits — measured across `services[].line`, +`ctor_line`, `root_resolve_sites[].line`, `effects[].line`, +`bindings[].line`, `params[].line`, `protocol_functions[].events[].line`, +`subscriptions[].column`, `params[].column` and flow-op `column`. All accept +`i64::MAX + 1`, `u64::MAX`, `u64::MAX + 1` and below `i64::MIN`. The single +exception is `ownir_version`, rejected for its *value*, not its width. + +Rust rejects all of them. That is a real Python-accept/Rust-reject divergence +across seven field families, not one stray column. + +It is **not** closed by widening Rust: threading arbitrary-precision integers +through `own-ir` and then the bridge, so that a source coordinate of nine +quintillion can round-trip, would be an expensive way to honour a file no +extractor can produce. The controls here therefore pin the boundary at +`i64::MAX`, where both sides agree, and stop. Closing the range above it is a +Python-first change — a documented defensive limit on source-coordinate +integers, which is what #259 asks for on externally supplied input — and lands +separately. This ledger is regenerated against that once it exists. + +## What is compared, and what deliberately is not + +Compared: **accepted / rejected**, and on rejection the **category**. Not the +message text. #259 asks for a matching error *class/category*; Python funnels +everything into one `OwnIRError` whose strings are a human-facing presentation +aid, so byte-comparing them across two languages would freeze a debug surface +as a contract and fail on every rewording. + +## Both failure directions are defects + +* **Python reject / Rust accept** — the higher-severity direction: the strict + door is the gate over untrusted extractor output, and a permissive Rust door + would analyse facts the reference refuses. +* **Python accept / Rust reject** — not a security hole but a production + outage after cutover: extractor output that works today would stop being + analysable. + +Both must be zero. So must category mismatches. + +## Order is part of the contract + +BR-D1 fixes the order of checks and notes it "is observable through which error +fires first". A document that violates two rules therefore has one *correct* +category, not two acceptable ones — the `order-*` controls below pin that +precedence rather than leaving it to whichever check an implementation happens +to run first. + +Run: python tests/test_ownir_validation_fixtures.py (verify) + python tests/test_ownir_validation_fixtures.py --write (regenerate) + python tests/run_tests.py (in the suite) +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +from typing import Any + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang.ownir import OWNIR_VERSION, OwnIRError, load + +FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "ownir_validation.json") + +SCHEMA_VERSION = 1 + +ROOT_KEYS = {"comment", "schema_version", "categories", "totals", "cases"} +CASE_KEYS = {"name", "why", "section", "document", "raw", "verdict", + "category", "message"} + +# The candidate taxonomy. Deliberately small: one entry per *mechanism* a +# loader can reject on, not one per message. A category is only worth having if +# two implementations could plausibly disagree about which one applies. +# +# `reference` is intentionally ABSENT until the sweep proves the strict door has +# a load-time referential constraint. Adding it now, on the strength of the +# issue text alone, would be inventing a category no control can exercise. +CATEGORIES = { + "json": "the document is not JSON at all", + "version": "the `ownir_version` gate — type or value", + "shape": "right place, wrong JSON type or container shape", + "vocabulary": "right JSON type, value outside a closed set", + "identity": "a name slot — empty, mistyped, or duplicated", + "location": "a source coordinate violating the 1-based contract", + "well_formedness": ("right types, legal vocabulary, and the record still " + "cannot mean anything"), +} + +# The largest integer both loaders accept. Above it Python keeps going and Rust +# stops; see "Integer width" in the module docstring for the measurement and why +# that boundary is closed elsewhere. +I64_MAX = 9223372036854775807 + + +def _c(name: str, section: str, why: str, document: Any, + category: str | None, raw: bool = False) -> dict[str, Any]: + """One control. `category` is None for a document that must be ACCEPTED. + + `raw=True` means `document` is the file's literal TEXT, not a value to + serialize — the only way to reach the JSON-parse branch. It is an explicit + flag rather than "a `str` document is text", because that heuristic + silently mis-encoded `root-string`: the intent was the JSON document + `"hello"`, and it was written as the unparseable bytes `hello`, which then + produced a category mismatch that looked like a port bug. + """ + assert category is None or category in CATEGORIES, f"{name}: bad category" + return {"name": name, "section": section, "why": why, + "document": document, "category": category, "raw": raw} + + +def _svc(**kw: Any) -> dict[str, Any]: + """A service record with both REQUIRED fields supplied, so a control that + overrides one field isolates that field. + + Its convenience is also its hazard, and this is where the first census went + wrong: because `_svc()` always sets `lifetime` and `name`, no control built + through it can express *absence*, and absence is exactly what the reference + rejects and the port accepted. The `service-*-absent` controls below are + therefore written as literal dicts, deliberately bypassing this helper. + """ + base: dict[str, Any] = {"name": "S", "lifetime": "singleton"} + base.update(kw) + return base + + +def _proto(**kw: Any) -> dict[str, Any]: + """A minimally VALID protocol: a name plus an opens/closes matcher pair. + + Both matchers state their written boolean, because an `opens`/`closes` + assign matcher that does not is refused ("any write cannot open or close an + obligation"). `exit_barriers` defaults true, so the record also clears the + never-fires rule. + + The valid baseline is the point. `protocol-duplicate-name` previously used + records with no `opens`/`closes` at all, so the reference rejected them for + record shape and never reached the duplicate-name branch — the control + passed while testing something else entirely. + """ + base: dict[str, Any] = { + "name": "P", + "opens": {"kind": "assign", "target": "flag", "value": True}, + "closes": {"kind": "assign", "target": "flag", "value": False}, + } + base.update(kw) + return base + + +def _open(**kw: Any) -> dict[str, Any]: + """A protocol whose `opens` matcher is replaced wholesale — the shortest way + to reach one matcher rule without disturbing the rest of the record.""" + return _proto(opens=dict(kw)) + + +def _pfn(**kw: Any) -> dict[str, Any]: + """A minimally valid `protocol_functions[]` record: `events` defaults to + empty, so a name alone is accepted.""" + base: dict[str, Any] = {"name": "M"} + base.update(kw) + return base + + +def _ev(*events: Any) -> dict[str, Any]: + """A document carrying one protocol function with the given event list.""" + return {"ownir_version": 0, "protocol_functions": [_pfn(events=list(events))]} + + +def _controls() -> list[dict[str, Any]]: + """Every BR-D1 rejection family, each with a neighbouring valid twin. + + A rejection control alone proves nothing about the boundary — it could pass + against a loader that rejects everything. The valid twin is what makes each + pair *discriminating*. + """ + return [ + # ---- acceptance twins ------------------------------------------------ + _c("accept-empty-object", "root", + "the minimal document: absent `ownir_version` means current, and " + "every section is optional", {}, None), + _c("accept-explicit-version", "version", + "the current version stated explicitly", {"ownir_version": OWNIR_VERSION}, None), + _c("accept-all-sections-empty", "root", + "every known section present but empty — shape-valid, nothing to check", + {"ownir_version": 0, "components": [], "services": [], "effects": [], + "functions": [], "protocols": [], "protocol_functions": []}, None), + _c("accept-unknown-top-level-key", "root", + "an unrecognised TOP-LEVEL key is additive and accepted; the strict " + "door gates known vocabulary, it is not a closed-world schema", + {"ownir_version": 0, "future_section": [{"whatever": 1}]}, None), + _c("accept-subscription-defaults", "components", + "a subscription with no `resource` defaults to 'subscription', which " + "is a known kind", + {"ownir_version": 0, "components": [{"subscriptions": [{}]}]}, None), + _c("accept-every-known-resource-kind", "components", + "all eight known kinds in one document — the acceptance side of the " + "closed vocabulary", + {"ownir_version": 0, "components": [{"subscriptions": [ + {"resource": k} for k in + ("capture", "disposable", "local-disposable", "pool", + "subscribe", "subscription", "timer", "unresolved-subscription") + ]}]}, None), + _c("accept-column-one", "components", + "column 1 is the smallest legal column — the boundary just inside the " + "1-based contract", + {"ownir_version": 0, + "components": [{"subscriptions": [{"column": 1}]}]}, None), + _c("accept-every-lifetime", "services", + "all three DI lifetimes accepted", + {"ownir_version": 0, "services": [ + _svc(name="A", lifetime="singleton"), + _svc(name="B", lifetime="scoped"), + _svc(name="C", lifetime="transient")]}, None), + _c("accept-every-param-effect", "functions", + "all four parameter effects accepted", + {"ownir_version": 0, "functions": [{"params": [ + {"name": "a", "effect": "plain"}, {"name": "b", "effect": "borrow"}, + {"name": "c", "effect": "borrow_mut"}, + {"name": "d", "effect": "consume"}]}]}, None), + _c("accept-param-effect-absent", "functions", + "`effect` is optional — absent is not the same as an unknown value", + {"ownir_version": 0, "functions": [{"params": [{"name": "a"}]}]}, None), + + # ---- json ------------------------------------------------------------ + _c("json-not-parseable", "json", + "a truncated document is not JSON at all — rejected before any " + "shape check can run", "{not json", "json", raw=True), + + # ---- root shape ------------------------------------------------------ + _c("root-array", "root", "the root must be an object, not an array", + [1, 2, 3], "shape"), + _c("root-string", "root", "nor a bare string", "hello", "shape"), + _c("root-null", "root", "nor null", None, "shape"), + + # ---- version gate ---------------------------------------------------- + _c("version-mismatch", "version", + "a different schema version makes every later check meaningless", + {"ownir_version": 99}, "version"), + _c("version-string", "version", "`ownir_version` must be an integer", + {"ownir_version": "0"}, "version"), + _c("version-bool", "version", + "the bool-is-int trap: `True` would otherwise read as version 1", + {"ownir_version": True}, "version"), + _c("version-float", "version", "a float is not an integer", + {"ownir_version": 0.0}, "version"), + _c("version-null", "version", + "explicit null is NOT the same as absent — absent defaults to " + "current, null is a stated non-integer", + {"ownir_version": None}, "version"), + + # ---- components / subscriptions ------------------------------------- + _c("components-object", "components", + "`components` must be an array", {"ownir_version": 0, + "components": {"a": 1}}, "shape"), + _c("components-of-scalars", "components", + "an array, but not of objects", + {"ownir_version": 0, "components": [1, 2]}, "shape"), + _c("subscriptions-not-array", "components", + "each component's `subscriptions` must be an array of objects", + {"ownir_version": 0, "components": [{"subscriptions": 7}]}, "shape"), + _c("subscriptions-of-scalars", "components", + "…of OBJECTS, not scalars", + {"ownir_version": 0, "components": [{"subscriptions": ["x"]}]}, "shape"), + _c("resource-not-string", "components", + "`resource` must be a string before its value can be checked", + {"ownir_version": 0, + "components": [{"subscriptions": [{"resource": 7}]}]}, "shape"), + _c("resource-unknown", "components", + "IR4: a present-but-unknown kind changes routing, so it is rejected " + "at the door rather than mis-routed. A new kind must bump " + "OWNIR_VERSION", + {"ownir_version": 0, + "components": [{"subscriptions": [{"resource": "bogus"}]}]}, "vocabulary"), + _c("resource-empty-string", "components", + "the empty string is present-but-unknown, not absent", + {"ownir_version": 0, + "components": [{"subscriptions": [{"resource": ""}]}]}, "vocabulary"), + _c("subscription-type-not-string", "components", + "optional `type`, present ⇒ string", + {"ownir_version": 0, + "components": [{"subscriptions": [{"type": 7}]}]}, "shape"), + _c("subscription-source-type-not-string", "components", + "optional `source_type`, present ⇒ string", + {"ownir_version": 0, + "components": [{"subscriptions": [{"source_type": 7}]}]}, "shape"), + _c("subscription-source-provenance-not-string", "components", + "optional `source_provenance`, present ⇒ string", + {"ownir_version": 0, + "components": [{"subscriptions": [{"source_provenance": 7}]}]}, "shape"), + _c("subscription-ignore-reason-not-string", "components", + "optional `ignore_reason`, present ⇒ string", + {"ownir_version": 0, + "components": [{"subscriptions": [{"ignore_reason": 7}]}]}, "shape"), + _c("column-zero", "components", + "#317: a column is 1-based or absent. `0` is a producer bug, and " + "reading it as 'unknown' would hide the bug while looking correct", + {"ownir_version": 0, + "components": [{"subscriptions": [{"column": 0}]}]}, "location"), + _c("column-negative", "components", "…and so is a negative column", + {"ownir_version": 0, + "components": [{"subscriptions": [{"column": -1}]}]}, "location"), + _c("column-bool", "components", + "the bool-is-int trap again: `True` would otherwise be accepted as " + "column 1 — a fabricated coordinate", + {"ownir_version": 0, + "components": [{"subscriptions": [{"column": True}]}]}, "location"), + _c("column-string", "components", "a string column is not a coordinate", + {"ownir_version": 0, + "components": [{"subscriptions": [{"column": "3"}]}]}, "location"), + + # ---- services -------------------------------------------------------- + _c("services-not-array", "services", "`services` must be an array of objects", + {"ownir_version": 0, "services": {"a": 1}}, "shape"), + _c("service-lifetime-unknown", "services", + "the DI lifetime is a closed set", + {"ownir_version": 0, "services": [_svc(lifetime="eternal")]}, "vocabulary"), + _c("service-name-empty", "services", + "the service name is the identity the DI graph joins on", + {"ownir_version": 0, "services": [_svc(name="")]}, "identity"), + _c("service-name-not-string", "services", "…and it must be a string", + {"ownir_version": 0, "services": [_svc(name=7)]}, "identity"), + _c("service-deps-not-array", "services", "`deps` is an array of strings", + {"ownir_version": 0, "services": [_svc(deps="a")]}, "shape"), + _c("service-deps-of-ints", "services", "…of STRINGS", + {"ownir_version": 0, "services": [_svc(deps=[1])]}, "shape"), + _c("service-weak-deps-of-ints", "services", "same for `weak_deps`", + {"ownir_version": 0, "services": [_svc(weak_deps=[1])]}, "shape"), + _c("service-root-resolves-of-ints", "services", "same for `root_resolves`", + {"ownir_version": 0, "services": [_svc(root_resolves=[1])]}, "shape"), + _c("service-scope-cached-of-ints", "services", "same for `scope_cached`", + {"ownir_version": 0, "services": [_svc(scope_cached=[1])]}, "shape"), + _c("service-file-not-string", "services", "`file` is a string", + {"ownir_version": 0, "services": [_svc(file=7)]}, "shape"), + _c("service-line-not-int", "services", "`line` is an integer", + {"ownir_version": 0, "services": [_svc(line="3")]}, "shape"), + _c("service-line-bool", "services", "…and a bool is not an integer", + {"ownir_version": 0, "services": [_svc(line=True)]}, "shape"), + _c("service-ctor-file-not-string", "services", "`ctor_file` is a string", + {"ownir_version": 0, "services": [_svc(ctor_file=7)]}, "shape"), + _c("service-ctor-line-not-int", "services", "`ctor_line` is an integer", + {"ownir_version": 0, "services": [_svc(ctor_line="3")]}, "shape"), + _c("service-ctor-type-not-string", "services", "`ctor_type` is a string", + {"ownir_version": 0, "services": [_svc(ctor_type=7)]}, "shape"), + _c("service-root-resolve-sites-not-objects", "services", + "`root_resolve_sites` is an array of {type,file,line} objects", + {"ownir_version": 0, "services": [_svc(root_resolve_sites=["x"])]}, "shape"), + _c("service-scope-cache-sites-not-objects", "services", + "same for `scope_cache_sites`", + {"ownir_version": 0, "services": [_svc(scope_cache_sites=["x"])]}, "shape"), + + # ---- effects --------------------------------------------------------- + _c("effects-not-array", "effects", "`effects` must be an array of objects", + {"ownir_version": 0, "effects": {"a": 1}}, "shape"), + _c("effect-deps-of-ints", "effects", "`deps` is an array of strings", + {"ownir_version": 0, "effects": [{"deps": [1]}]}, "shape"), + _c("effect-io-not-bool", "effects", "`io` is a boolean", + {"ownir_version": 0, "effects": [{"io": "yes"}]}, "shape"), + _c("effect-line-not-int", "effects", "`line` is an integer", + {"ownir_version": 0, "effects": [{"line": "3"}]}, "shape"), + _c("effect-bindings-not-objects", "effects", + "`bindings` is an array of objects", + {"ownir_version": 0, "effects": [{"bindings": ["x"]}]}, "shape"), + _c("binding-name-not-string", "effects", "binding `name` is a string", + {"ownir_version": 0, "effects": [{"bindings": [{"name": 7}]}]}, "shape"), + _c("binding-init-not-string", "effects", "binding `init` is a string", + {"ownir_version": 0, "effects": [{"bindings": [{"init": 7}]}]}, "shape"), + _c("binding-refs-of-ints", "effects", "binding `refs` is string array", + {"ownir_version": 0, "effects": [{"bindings": [{"refs": [1]}]}]}, "shape"), + _c("binding-line-not-int", "effects", "binding `line` is an integer", + {"ownir_version": 0, "effects": [{"bindings": [{"line": "3"}]}]}, "shape"), + + # ---- functions / params --------------------------------------------- + _c("functions-not-array", "functions", "`functions` must be an array of objects", + {"ownir_version": 0, "functions": {"a": 1}}, "shape"), + _c("function-sig-not-string", "functions", + "`sig` present ⇒ string. It is the overload key MOS resolution joins " + "on, so a non-string is not a cosmetic problem", + {"ownir_version": 0, "functions": [{"sig": 7}]}, "shape"), + _c("params-not-array", "functions", "`params` is an array of objects", + {"ownir_version": 0, "functions": [{"params": "x"}]}, "shape"), + _c("params-of-scalars", "functions", "…of OBJECTS", + {"ownir_version": 0, "functions": [{"params": ["x"]}]}, "shape"), + _c("param-name-empty", "functions", + "the parameter name is the identity effects attach to", + {"ownir_version": 0, "functions": [{"params": [{"name": ""}]}]}, "identity"), + _c("param-name-not-string", "functions", "…and it must be a string", + {"ownir_version": 0, "functions": [{"params": [{"name": 7}]}]}, "identity"), + _c("param-line-not-int", "functions", "param `line` is an integer", + {"ownir_version": 0, + "functions": [{"params": [{"name": "p", "line": "3"}]}]}, "shape"), + _c("param-effect-unknown", "functions", + "the parameter effect is a closed set", + {"ownir_version": 0, + "functions": [{"params": [{"name": "p", "effect": "teleport"}]}]}, + "vocabulary"), + + # ---- protocols ------------------------------------------------------- + _c("protocols-not-array", "protocols", "`protocols` must be an array of objects", + {"ownir_version": 0, "protocols": {"a": 1}}, "shape"), + _c("protocol-duplicate-name", "protocols", + "the protocol name is the identity verdicts map back by, so a " + "duplicate is an identity collision, not a harmless repeat. Both " + "records are individually VALID — the older version of this control " + "used records with no `opens`/`closes`, which the reference refused " + "for record shape long before it compared any names", + {"ownir_version": 0, + "protocols": [_proto(name="P"), _proto(name="P")]}, "identity"), + _c("protocol-functions-not-array", "protocols", + "`protocol_functions` must be an array of objects", + {"ownir_version": 0, "protocol_functions": {"a": 1}}, "shape"), + + # ---- order discrimination ------------------------------------------- + # BR-D1 fixes the ORDER of checks and says it "is observable through + # which error fires first". Each of these violates two rules at once, so + # there is exactly one correct category — a loader that runs its checks + # in a different order reports the other one and fails here. + _c("order-version-before-components", "order", + "both the version and `components` are wrong; the VERSION gate runs " + "first, because a vocabulary mismatch makes every later shape check " + "meaningless", + {"ownir_version": 99, "components": {"a": 1}}, "version"), + _c("order-version-before-resource", "order", + "version wins over an unknown resource kind for the same reason", + {"ownir_version": 99, + "components": [{"subscriptions": [{"resource": "bogus"}]}]}, "version"), + _c("order-root-before-version", "order", + "root-is-object precedes the version gate — there is nowhere to read " + "`ownir_version` from until the root is an object", + [{"ownir_version": 99}], "shape"), + _c("order-json-before-everything", "order", + "an unparseable document cannot reach any structural check", + "{\"ownir_version\": 99", "json", raw=True), + _c("order-resource-shape-before-vocabulary", "order", + "`resource` must be a STRING before its value can be tested against " + "the closed set — a shape failure, not a vocabulary one", + {"ownir_version": 0, + "components": [{"subscriptions": [{"resource": 7, "column": 0}]}]}, + "shape"), + _c("order-components-before-services", "order", + "sections are validated in declaration order: `components` before " + "`services`, so a document breaking both reports components", + {"ownir_version": 0, "components": {"a": 1}, + "services": {"b": 2}}, "shape"), + + # ==== second census =================================================== + # Everything below was added after review found seven divergences the + # controls above structurally could not express. Grouped by the blind + # spot rather than by section, so the gap each one closes stays legible. + + # ---- required fields, absent (the `_svc()` blind spot) --------------- + # A helper that always supplies a required field makes the absent case + # unreachable. These bypass the helper on purpose. + _c("service-lifetime-absent", "services", + "there is no default lifetime: an absent one is `None`, which is " + "outside the closed set exactly like a misspelt one. This is the " + "case `_svc()` could not express, and the port accepted it", + {"ownir_version": 0, "services": [{"name": "S"}]}, "vocabulary"), + _c("service-empty-record", "services", + "a service record with nothing in it: the lifetime gate fires first, " + "so this is a vocabulary failure and not a missing-name one", + {"ownir_version": 0, "services": [{}]}, "vocabulary"), + _c("service-lifetime-null", "services", + "an explicit null is the same as absent here — both are `None`, and " + "neither is in the closed set", + {"ownir_version": 0, + "services": [{"name": "S", "lifetime": None}]}, "vocabulary"), + _c("service-name-absent", "services", + "with a valid lifetime, an absent name reaches the identity check", + {"ownir_version": 0, "services": [{"lifetime": "singleton"}]}, + "identity"), + + # ---- ordering WITHIN a section -------------------------------------- + # BR-D1's order is per-field, not merely per-section. Each of these + # breaks two rules inside one record. + _c("order-lifetime-before-name", "order", + "the reference checks `lifetime` BEFORE `name`, so a record that " + "breaks both is a vocabulary failure. A port that validates identity " + "first reports `identity` and is wrong about which rule fired", + {"ownir_version": 0, + "services": [{"name": "", "lifetime": "eternal"}]}, "vocabulary"), + _c("order-name-before-deps", "order", + "…and `name` before the remaining service fields", + {"ownir_version": 0, + "services": [{"lifetime": "singleton", "name": "", "deps": 7}]}, + "identity"), + _c("order-resource-vocabulary-before-column", "order", + "within one subscription, the resource vocabulary precedes the " + "column contract", + {"ownir_version": 0, "components": [{"subscriptions": [ + {"resource": "bogus", "column": 0}]}]}, "vocabulary"), + _c("order-column-before-subscription-type", "order", + "…and the column precedes the optional `type`", + {"ownir_version": 0, "components": [{"subscriptions": [ + {"column": 0, "type": 7}]}]}, "location"), + _c("order-sig-before-body-column", "order", + "inside a function, `sig` is checked before the body's columns", + {"ownir_version": 0, "functions": [ + {"sig": 7, "body": [{"op": "x", "column": 0}]}]}, "shape"), + _c("order-body-column-before-param-name", "order", + "and the BODY's columns are checked before `params` — the least " + "obvious edge in the whole door, because params read like the more " + "primitive thing", + {"ownir_version": 0, "functions": [ + {"body": [{"op": "x", "column": 0}], + "params": [{"name": ""}]}]}, "location"), + _c("order-param-name-before-param-line", "order", + "within a param, identity precedes the line", + {"ownir_version": 0, "functions": [ + {"params": [{"name": "", "line": "3"}]}]}, "identity"), + _c("order-param-line-before-param-column", "order", + "…the line precedes the column", + {"ownir_version": 0, "functions": [ + {"params": [{"name": "p", "line": "3", "column": 0}]}]}, "shape"), + _c("order-param-column-before-effect", "order", + "…and the column precedes the effect vocabulary", + {"ownir_version": 0, "functions": [ + {"params": [{"name": "p", "column": 0, "effect": "teleport"}]}]}, + "location"), + + # ---- ordering ACROSS sections --------------------------------------- + # A "run every semantic check, then every shape check" architecture + # passes section-local controls and fails all of these: BR-D1 interleaves + # shape and semantics per section, in declaration order. + _c("order-components-shape-before-services-vocabulary", "order", + "a components SHAPE failure outranks a services VOCABULARY failure, " + "because components is validated first — completely. A door that " + "runs all semantic gates before any shape check reports the " + "vocabulary error and inverts the contract", + {"ownir_version": 0, "components": {"a": 1}, + "services": [{"lifetime": "bad"}]}, "shape"), + _c("order-components-location-before-services-vocabulary", "order", + "same precedence with a column failure standing in for the shape one", + {"ownir_version": 0, + "components": [{"subscriptions": [{"column": 0}]}], + "services": [{"lifetime": "bad"}]}, "location"), + _c("order-services-vocabulary-before-effects-shape", "order", + "and the same one section later: services before effects", + {"ownir_version": 0, "services": [{"lifetime": "bad"}], + "effects": {"a": 1}}, "vocabulary"), + _c("order-effects-shape-before-functions-identity", "order", + "effects before functions", + {"ownir_version": 0, "effects": {"a": 1}, + "functions": [{"params": [{"name": ""}]}]}, "shape"), + _c("order-functions-identity-before-protocols-shape", "order", + "functions before protocols", + {"ownir_version": 0, "functions": [{"params": [{"name": ""}]}], + "protocols": 7}, "identity"), + _c("order-protocols-identity-before-protocol-functions-shape", "order", + "protocols before protocol_functions — the last edge in the chain", + {"ownir_version": 0, "protocols": [{"name": ""}], + "protocol_functions": 7}, "identity"), + + # ---- flow columns, including nested bodies --------------------------- + # `_check_flow_columns` recurses through `then`/`else`/`body`. A hoisted + # branch acquire is the path most likely to be forgotten, and it is + # never at the top level. + _c("accept-flow-column-one", "functions", + "column 1 on a flow op, nested and not — the acceptance twin the " + "rejections below need", + {"ownir_version": 0, "functions": [{"body": [ + {"op": "acquire", "column": 1}, + {"op": "if", "then": [{"op": "release", "column": 1}], + "else": [{"op": "call", "column": 1}]}, + {"op": "while", "body": [{"op": "acquire", "column": 1}]}]}]}, + None), + _c("accept-flow-body-not-array", "functions", + "a non-array `body` is SKIPPED, not rejected: the reference returns " + "early rather than raising. Pinned because it is the kind of " + "tolerance a port silently tightens", + {"ownir_version": 0, "functions": [{"body": 7}]}, None), + _c("accept-flow-body-of-scalars", "functions", + "…and a non-object op inside the body is skipped the same way", + {"ownir_version": 0, "functions": [{"body": [1, "x", None]}]}, None), + _c("flow-column-zero", "functions", + "the 1-based contract reaches flow ops, not just subscriptions", + {"ownir_version": 0, + "functions": [{"body": [{"op": "acquire", "column": 0}]}]}, + "location"), + _c("flow-column-negative", "functions", "…negative likewise", + {"ownir_version": 0, + "functions": [{"body": [{"op": "acquire", "column": -2}]}]}, + "location"), + _c("flow-column-bool", "functions", "…and the bool-is-int trap", + {"ownir_version": 0, + "functions": [{"body": [{"op": "acquire", "column": True}]}]}, + "location"), + _c("flow-column-in-if-then", "functions", + "recursion into `then` — a hoisted branch acquire", + {"ownir_version": 0, "functions": [{"body": [ + {"op": "if", "then": [{"op": "acquire", "column": 0}]}]}]}, + "location"), + _c("flow-column-in-if-else", "functions", "…into `else`", + {"ownir_version": 0, "functions": [{"body": [ + {"op": "if", "else": [{"op": "acquire", "column": 0}]}]}]}, + "location"), + _c("flow-column-in-while-body", "functions", "…into a loop `body`", + {"ownir_version": 0, "functions": [{"body": [ + {"op": "while", "body": [{"op": "acquire", "column": 0}]}]}]}, + "location"), + _c("flow-column-deeply-nested", "functions", + "…and through three levels, so a one-level-deep port fails here", + {"ownir_version": 0, "functions": [{"body": [ + {"op": "if", "then": [{"op": "while", "body": [ + {"op": "if", "else": [{"op": "acquire", "column": 0}]}]}]}]}]}, + "location"), + + # ---- params[].column ------------------------------------------------- + _c("accept-param-column-one", "functions", + "the 1-based boundary on a parameter coordinate", + {"ownir_version": 0, + "functions": [{"params": [{"name": "p", "column": 1}]}]}, None), + _c("param-column-zero", "functions", + "the same contract on `params[].column` — a separate call site in " + "the reference, and one the port did not have at all", + {"ownir_version": 0, + "functions": [{"params": [{"name": "p", "column": 0}]}]}, + "location"), + _c("param-column-negative", "functions", "…negative likewise", + {"ownir_version": 0, + "functions": [{"params": [{"name": "p", "column": -1}]}]}, + "location"), + _c("param-column-bool", "functions", "…and the bool-is-int trap", + {"ownir_version": 0, + "functions": [{"params": [{"name": "p", "column": True}]}]}, + "location"), + _c("param-column-string", "functions", "…and a string is no coordinate", + {"ownir_version": 0, + "functions": [{"params": [{"name": "p", "column": "3"}]}]}, + "location"), + + # ---- protocols: the acceptance grammar ------------------------------ + # `load()` delegates each record to the shared obligation parser + # (ownlang/obligations.py), which is part of the strict-door contract + # even though it lives in another module. cp1 ports its ACCEPTANCE + # grammar only — parse_protocol / parse_matcher / parse_events / + # parse_method. Protocol ANALYSIS (the lattice, matching, verdicts) is + # not in this checkpoint and no control below reaches it. + _c("accept-protocol-minimal", "protocols", + "a name plus an opens/closes pair is a complete protocol", + {"ownir_version": 0, "protocols": [_proto()]}, None), + _c("accept-protocol-full", "protocols", + "every optional key at once: barriers, allow with narrowed args, an " + "explicit scope, a description, and `exit_barriers` false — legal " + "only BECAUSE a barrier is present", + {"ownir_version": 0, "protocols": [_proto( + barriers=[{"kind": "call", "callee": "Barrier"}], + allow=[{"kind": "call", "callee": "Allowed", "args": ["x"]}], + exit_barriers=False, + scope={"methods": ["Type.Method"]}, + description="d")]}, None), + _c("accept-protocol-call-matchers", "protocols", + "the other matcher kind: `call`, with and without narrowed args", + {"ownir_version": 0, "protocols": [_proto( + opens={"kind": "call", "callee": "Begin"}, + closes={"kind": "call", "callee": "End", "args": ["a", "b"]})]}, + None), + _c("accept-two-distinct-protocols", "protocols", + "two protocols with DIFFERENT names — the twin that makes the " + "duplicate-name rejection discriminating rather than a test of " + "'more than one protocol is refused'", + {"ownir_version": 0, + "protocols": [_proto(name="A"), _proto(name="B")]}, None), + _c("protocol-not-object", "protocols", + "`protocols` is checked only for LIST-ness at the top; a scalar " + "entry is refused by the record parser", + {"ownir_version": 0, "protocols": [7]}, "shape"), + _c("protocol-null-entry", "protocols", "…and null is not a record", + {"ownir_version": 0, "protocols": [None]}, "shape"), + _c("protocol-name-absent", "protocols", + "the protocol name is required, not defaulted", + {"ownir_version": 0, "protocols": [{"opens": {}, "closes": {}}]}, + "identity"), + _c("protocol-name-empty", "protocols", "…and must be non-empty", + {"ownir_version": 0, "protocols": [_proto(name="")]}, "identity"), + _c("protocol-name-not-string", "protocols", "…and a string", + {"ownir_version": 0, "protocols": [_proto(name=7)]}, "identity"), + _c("protocol-missing-opens", "protocols", + "a protocol without `opens` cannot state what it is tracking", + {"ownir_version": 0, "protocols": [ + {"name": "P", "closes": {"kind": "call", "callee": "E"}}]}, + "shape"), + _c("protocol-missing-closes", "protocols", "…and likewise `closes`", + {"ownir_version": 0, "protocols": [ + {"name": "P", "opens": {"kind": "call", "callee": "B"}}]}, + "shape"), + _c("protocol-opens-not-object", "protocols", "a matcher is an object", + {"ownir_version": 0, "protocols": [_proto(opens=7)]}, "shape"), + _c("protocol-matcher-unknown-kind", "protocols", + "the matcher kind is a closed vocabulary (assign | call), fail-loud " + "like a flow op", + {"ownir_version": 0, + "protocols": [_open(kind="teleport", target="f")]}, "vocabulary"), + _c("protocol-matcher-kind-absent", "protocols", + "…and absent is outside that set too", + {"ownir_version": 0, "protocols": [_open(target="f")]}, "vocabulary"), + _c("protocol-assign-matcher-target-empty", "protocols", + "the assign target is the member name the rule joins events by", + {"ownir_version": 0, + "protocols": [_open(kind="assign", target="", value=True)]}, + "identity"), + _c("protocol-assign-matcher-without-value", "protocols", + "an opens/closes assign matcher must state the written boolean — " + "'any write opens' is not a checkable protocol", + {"ownir_version": 0, + "protocols": [_open(kind="assign", target="f")]}, "shape"), + _c("protocol-assign-matcher-value-not-bool", "protocols", + "…and that value is a boolean", + {"ownir_version": 0, + "protocols": [_open(kind="assign", target="f", value="yes")]}, + "shape"), + _c("protocol-call-matcher-callee-empty", "protocols", + "the callee is the name a call event joins by", + {"ownir_version": 0, "protocols": [_open(kind="call", callee="")]}, + "identity"), + _c("protocol-call-matcher-args-not-strings", "protocols", + "narrowed args are strings", + {"ownir_version": 0, + "protocols": [_open(kind="call", callee="C", args=[1])]}, "shape"), + _c("protocol-barriers-not-array", "protocols", "`barriers` is an array", + {"ownir_version": 0, "protocols": [_proto(barriers=7)]}, "shape"), + _c("protocol-allow-not-array", "protocols", "`allow` is an array", + {"ownir_version": 0, "protocols": [_proto(allow=7)]}, "shape"), + _c("protocol-exit-barriers-not-bool", "protocols", + "`exit_barriers` is a boolean", + {"ownir_version": 0, "protocols": [_proto(exit_barriers="no")]}, + "shape"), + _c("protocol-never-fires", "protocols", + "no barriers AND no exit barriers: the rule can structurally never " + "fire, which the reference refuses as decoration. Every value here " + "is correctly typed and in vocabulary — what fails is meaning, so " + "the category is `well_formedness`", + {"ownir_version": 0, "protocols": [_proto(exit_barriers=False)]}, + "well_formedness"), + _c("protocol-barrier-equals-opens", "protocols", + "a barrier identical to `opens` is dead — the walk checks opens " + "first, so the barrier can never fire. Same category, different " + "mechanism: the record is well-typed and means nothing", + {"ownir_version": 0, "protocols": [_proto( + barriers=[{"kind": "assign", "target": "flag", "value": True}])]}, + "well_formedness"), + _c("accept-protocol-fires-via-barrier", "protocols", + "`exit_barriers: false` is legal WITH a barrier — the twin that " + "makes `protocol-never-fires` a rejection about meaning rather than " + "a rejection of the field", + {"ownir_version": 0, "protocols": [_proto( + exit_barriers=False, + barriers=[{"kind": "call", "callee": "Barrier"}])]}, None), + _c("accept-protocol-barrier-differs-from-opens", "protocols", + "…and a barrier that is not `opens` is legal, which is what makes " + "the equality the defect rather than the presence", + {"ownir_version": 0, "protocols": [_proto( + barriers=[{"kind": "assign", "target": "flag", "value": False}])]}, + None), + _c("protocol-scope-not-object", "protocols", "`scope` is an object", + {"ownir_version": 0, "protocols": [_proto(scope=7)]}, "shape"), + _c("protocol-scope-methods-not-array", "protocols", + "`scope.methods` is an array — a CONTAINER failure", + {"ownir_version": 0, "protocols": [_proto(scope={"methods": 7})]}, + "shape"), + _c("protocol-scope-methods-empty-entry", "protocols", + "…of non-empty method names. The reference raises one message for " + "this and the case above; the ledger separates them because a " + "missing container and an unusable name are different defects", + {"ownir_version": 0, + "protocols": [_proto(scope={"methods": [""]})]}, "identity"), + _c("protocol-description-not-string", "protocols", + "`description` is a string", + {"ownir_version": 0, "protocols": [_proto(description=7)]}, "shape"), + _c("order-protocol-name-before-opens", "order", + "the name is read before `opens`/`closes` are required, so a record " + "missing everything reports identity", + {"ownir_version": 0, "protocols": [{"name": ""}]}, "identity"), + _c("order-protocol-opens-before-closes", "order", + "`opens` is parsed before `closes`", + {"ownir_version": 0, "protocols": [ + _proto(opens=7, closes={"kind": "teleport"})]}, "shape"), + + # ---- protocol_functions: the event tree ------------------------------ + _c("accept-protocol-function-minimal", "protocol_functions", + "`events` defaults to empty, so a name alone is a valid record", + {"ownir_version": 0, "protocol_functions": [_pfn()]}, None), + _c("accept-protocol-function-every-event-kind", "protocol_functions", + "all six event kinds including the two recursive ones — the " + "acceptance side of the closed `ev` vocabulary", + _ev({"ev": "assign", "target": "f", "value": True, "line": 1}, + {"ev": "assign", "target": "f", "line": 2}, + {"ev": "call", "callee": "C", "arg": "x", "line": 3}, + {"ev": "call", "callee": "D", "line": 4}, + {"ev": "return", "line": 5}, + {"ev": "throw", "line": 6}, + {"ev": "if", "line": 7, "then": [{"ev": "return", "line": 8}], + "else": [{"ev": "throw", "line": 9}]}, + {"ev": "while", "line": 10, + "body": [{"ev": "call", "callee": "E", "line": 11}]}), None), + _c("protocol-function-not-object", "protocol_functions", + "like `protocols`, only list-ness is checked at the top", + {"ownir_version": 0, "protocol_functions": [7]}, "shape"), + _c("protocol-function-null-entry", "protocol_functions", + "…and null is not a record", + {"ownir_version": 0, "protocol_functions": [None]}, "shape"), + _c("protocol-function-name-empty", "protocol_functions", + "the method name is what a protocol's scope matches against", + {"ownir_version": 0, "protocol_functions": [_pfn(name="")]}, + "identity"), + _c("protocol-function-name-absent", "protocol_functions", + "…and it is required", + {"ownir_version": 0, "protocol_functions": [{"file": "a.cs"}]}, + "identity"), + _c("protocol-function-file-not-string", "protocol_functions", + "`file` is optional but, present, a string", + {"ownir_version": 0, "protocol_functions": [_pfn(file=7)]}, "shape"), + _c("protocol-function-events-not-array", "protocol_functions", + "`events` is an ordered array", + {"ownir_version": 0, "protocol_functions": [_pfn(events=7)]}, + "shape"), + _c("protocol-function-event-not-object", "protocol_functions", + "…of objects", + _ev(7), "shape"), + _c("protocol-function-event-unknown-kind", "protocol_functions", + "the `ev` discriminator is closed (IR4): a present-but-unknown value " + "is rejected, never skipped", + _ev({"ev": "teleport"}), "vocabulary"), + _c("protocol-function-event-kind-absent", "protocol_functions", + "…and an absent one is outside the set too", + _ev({"line": 1}), "vocabulary"), + _c("protocol-function-event-line-not-int", "protocol_functions", + "an event line is an integer", + _ev({"ev": "return", "line": "3"}), "shape"), + _c("protocol-function-event-line-bool", "protocol_functions", + "…and the bool-is-int trap reaches here as well", + _ev({"ev": "return", "line": True}), "shape"), + _c("protocol-function-assign-target-empty", "protocol_functions", + "an assign event's target is a name slot", + _ev({"ev": "assign", "target": ""}), "identity"), + _c("protocol-function-assign-value-not-bool", "protocol_functions", + "…its value is a boolean or absent (absent = an opaque write)", + _ev({"ev": "assign", "target": "f", "value": "yes"}), "shape"), + _c("protocol-function-call-callee-empty", "protocol_functions", + "a call event's callee is a name slot", + _ev({"ev": "call", "callee": ""}), "identity"), + _c("protocol-function-call-arg-not-string", "protocol_functions", + "…its distinguished arg is a string or absent", + _ev({"ev": "call", "callee": "C", "arg": 7}), "shape"), + _c("protocol-function-event-in-if-then", "protocol_functions", + "the event vocabulary is enforced recursively — inside `then`", + _ev({"ev": "if", "then": [{"ev": "teleport"}]}), "vocabulary"), + _c("protocol-function-event-in-if-else", "protocol_functions", + "…inside `else`", + _ev({"ev": "if", "else": [{"ev": "teleport"}]}), "vocabulary"), + _c("protocol-function-event-in-while-body", "protocol_functions", + "…and inside a loop body", + _ev({"ev": "while", "body": [{"ev": "teleport"}]}), "vocabulary"), + _c("protocol-function-event-deeply-nested", "protocol_functions", + "…through three levels, so a one-level-deep port fails here", + _ev({"ev": "if", "then": [{"ev": "while", "body": [ + {"ev": "assign", "target": ""}]}]}), "identity"), + _c("order-event-kind-before-line", "order", + "the `ev` vocabulary is tested before the line", + _ev({"ev": "teleport", "line": "x"}), "vocabulary"), + _c("order-event-line-before-assign-target", "order", + "…and the line before the per-kind fields", + _ev({"ev": "assign", "line": "x", "target": ""}), "shape"), + + # ---- explicit null on every section --------------------------------- + # `d.get(k, [])` returns None for a PRESENT null, so every one of these + # is a rejection — absent and null are not the same document. + _c("components-null", "root", "a present null is not an absent section", + {"ownir_version": 0, "components": None}, "shape"), + _c("services-null", "root", "…same for services", + {"ownir_version": 0, "services": None}, "shape"), + _c("effects-null", "root", "…effects", + {"ownir_version": 0, "effects": None}, "shape"), + _c("functions-null", "root", "…functions", + {"ownir_version": 0, "functions": None}, "shape"), + _c("protocols-null", "root", "…protocols", + {"ownir_version": 0, "protocols": None}, "shape"), + _c("protocol-functions-null", "root", "…and protocol_functions", + {"ownir_version": 0, "protocol_functions": None}, "shape"), + _c("subscriptions-null", "components", "…and one level down", + {"ownir_version": 0, "components": [{"subscriptions": None}]}, + "shape"), + _c("params-null", "functions", "…likewise params", + {"ownir_version": 0, "functions": [{"params": None}]}, "shape"), + _c("service-deps-null", "services", "…and a null string array", + {"ownir_version": 0, "services": [_svc(deps=None)]}, "shape"), + _c("effect-bindings-null", "effects", "…and null bindings", + {"ownir_version": 0, "effects": [{"bindings": None}]}, "shape"), + _c("service-line-null", "services", + "a null scalar is not an absent scalar either", + {"ownir_version": 0, "services": [_svc(line=None)]}, "shape"), + _c("service-file-null", "services", "…same for a string scalar", + {"ownir_version": 0, "services": [_svc(file=None)]}, "shape"), + _c("subscription-resource-null", "components", + "…and `resource` null fails the string check before the vocabulary", + {"ownir_version": 0, + "components": [{"subscriptions": [{"resource": None}]}]}, "shape"), + _c("accept-column-null", "components", + "the column is the exception: the reference returns early on None, " + "so an explicit null IS accepted where a null line is not", + {"ownir_version": 0, + "components": [{"subscriptions": [{"column": None}]}]}, None), + _c("accept-param-effect-null", "functions", + "…and a null effect is read as absent, not as an unknown value", + {"ownir_version": 0, + "functions": [{"params": [{"name": "p", "effect": None}]}]}, None), + _c("accept-function-sig-null", "functions", + "…and a null `sig` is accepted, because the check is `is not None`", + {"ownir_version": 0, "functions": [{"sig": None}]}, None), + + # ---- site records ---------------------------------------------------- + _c("accept-service-sites-full", "services", + "well-formed DI004/DI005 call-site metadata", + {"ownir_version": 0, "services": [_svc( + root_resolve_sites=[{"type": "T", "file": "a.cs", "line": 3}], + scope_cache_sites=[{"type": "U", "file": "b.cs", "line": 4}])]}, + None), + _c("accept-service-site-empty-object", "services", + "every site field is defaulted, so `{}` is a legal site", + {"ownir_version": 0, + "services": [_svc(root_resolve_sites=[{}])]}, None), + _c("service-site-line-bool", "services", + "the bool-is-int trap inside a site record", + {"ownir_version": 0, + "services": [_svc(root_resolve_sites=[{"line": True}])]}, "shape"), + _c("service-site-type-not-string", "services", + "…and a site `type` is a string", + {"ownir_version": 0, + "services": [_svc(root_resolve_sites=[{"type": 7}])]}, "shape"), + _c("service-scope-cache-site-line-bool", "services", + "…both site arrays carry the same contract", + {"ownir_version": 0, + "services": [_svc(scope_cache_sites=[{"line": True}])]}, "shape"), + + # ---- integer boundaries both loaders agree on ------------------------ + # Pinned at i64::MAX, where they still agree. Above it Python keeps + # accepting and Rust stops; see "Integer width" in the module docstring. + _c("accept-line-at-i64-max", "services", + "the largest line both loaders accept", + {"ownir_version": 0, "services": [_svc(line=I64_MAX)]}, None), + _c("accept-column-at-i64-max", "components", + "…and the largest column", + {"ownir_version": 0, + "components": [{"subscriptions": [{"column": I64_MAX}]}]}, None), + _c("accept-negative-line", "services", + "a NEGATIVE line is accepted: only columns carry the 1-based rule, " + "and conflating the two would tighten the door", + {"ownir_version": 0, "services": [_svc(line=-5)]}, None), + _c("accept-zero-line", "services", "…and zero is the line default", + {"ownir_version": 0, "services": [_svc(line=0)]}, None), + _c("column-float", "components", + "a float is not an integer coordinate even when it is whole", + {"ownir_version": 0, + "components": [{"subscriptions": [{"column": 1.0}]}]}, "location"), + _c("service-line-float", "services", + "…and the same for a line, where it is a shape failure instead", + {"ownir_version": 0, "services": [_svc(line=1.0)]}, "shape"), + ] + + +def _oracle(document: Any, raw: bool) -> tuple[str, str]: + """Run the reference strict door and record its verdict. + + A raw string document is written verbatim so the JSON-parse branch is + reachable; anything else is serialized. + + `load()` takes a PATH and splices it into two of its messages ("cannot read + {path}", "{path} is not valid JSON"), so the recorded message carries the + temporary filename. That is genuinely volatile — the ledger was + non-deterministic on the first regeneration until the path was normalized + to a fixed token here. The message is not compared across languages, but a + golden that changes on every run cannot detect anything at all. + """ + directory = tempfile.mkdtemp() + path = os.path.join(directory, "facts.ownir.json") + try: + with open(path, "w", encoding="utf-8") as f: + if raw: + f.write(document) + else: + json.dump(document, f) + try: + load(path) + return "accept", "" + except OwnIRError as e: + return "reject", str(e).replace(path, "") + finally: + if os.path.exists(path): + os.unlink(path) + os.rmdir(directory) + + +def build() -> dict[str, Any]: + cases: list[dict[str, Any]] = [] + for ctl in _controls(): + verdict, message = _oracle(ctl["document"], ctl["raw"]) + cases.append({ + "name": ctl["name"], + "why": ctl["why"], + "section": ctl["section"], + "document": ctl["document"], + "raw": ctl["raw"], + "verdict": verdict, + "category": ctl["category"], + # Kept for a human reading a failure, NOT compared across languages: + # #259 asks for a matching error class/category, and Python's + # strings are a presentation aid rather than a semantic surface. + "message": message, + }) + + by_category: dict[str, int] = {} + for case in cases: + key = case["category"] or "accepted" + by_category[key] = by_category.get(key, 0) + 1 + + return { + "comment": ( + "GENERATED by tests/test_ownir_validation_fixtures.py --write; do not " + "edit. Python (ownlang.ownir.load) is authoritative. The BR-D1 " + "strict-door acceptance language for P-022 step 6b, #259 checkpoint 1. " + "Compared across languages: accepted/rejected and, on rejection, the " + "CATEGORY -- never the message text, which is a human-facing " + "presentation aid in the reference." + ), + "schema_version": SCHEMA_VERSION, + "categories": CATEGORIES, + "totals": { + "cases": len(cases), + "accepted": sum(1 for c in cases if c["verdict"] == "accept"), + "rejected": sum(1 for c in cases if c["verdict"] == "reject"), + "by_category": dict(sorted(by_category.items())), + }, + "cases": cases, + } + + +def _render_json(data: dict[str, Any]) -> str: + return json.dumps(data, indent=2, ensure_ascii=False) + "\n" + + +def run() -> int: + fresh = build() + expected = _render_json(fresh) + if not os.path.exists(FIXTURE): + print(f"FAIL: {FIXTURE} missing; regenerate with " + f"'python tests/test_ownir_validation_fixtures.py --write'") + return 1 + with open(FIXTURE, encoding="utf-8") as f: + actual = f.read() + if actual != expected: + print(f"FAIL: {FIXTURE} is stale (the strict door's acceptance changed); " + f"regenerate with 'python tests/test_ownir_validation_fixtures.py " + f"--write' and re-run the Rust side " + f"(cd rust && cargo test -p own-ir)") + return 1 + + data = json.loads(actual) + if set(data) != ROOT_KEYS: + print(f"FAIL: fixture root keys {sorted(data)} != {sorted(ROOT_KEYS)}") + return 1 + for case in data["cases"]: + if set(case) != CASE_KEYS: + print(f"FAIL: case {case.get('name')!r} keys {sorted(case)} " + f"!= {sorted(CASE_KEYS)}") + return 1 + + # A rejection control with no category, or an acceptance control carrying + # one, would make the Rust comparison vacuous for that case. + for case in data["cases"]: + if case["verdict"] == "reject" and not case["category"]: + print(f"FAIL: {case['name']}: rejected with no category") + return 1 + if case["verdict"] == "accept" and case["category"]: + print(f"FAIL: {case['name']}: accepted but carries a category") + return 1 + + # Every declared category must be exercised. An unused category is a claim + # about the taxonomy that no control backs. + used = {c["category"] for c in data["cases"] if c["category"]} + unused = sorted(set(data["categories"]) - used) + if unused: + print(f"FAIL: declared categories with no control: {unused}. A category " + f"is only worth having if a case exercises it") + return 1 + + # The acceptance twins are what make the rejections discriminating: without + # them the ledger would pass against a loader that rejects everything. + accepted = data["totals"]["accepted"] + if accepted < 5: + print(f"FAIL: only {accepted} acceptance control(s); the rejections are " + f"not discriminating without valid twins") + return 1 + + # The oracle must be a pure function of the control. It was not, on the + # first attempt: `load()` splices its input path into two messages and the + # temporary filename changed every run. Asserted rather than assumed, so a + # future volatile field cannot make the golden quietly self-defeating. + if _render_json(build()) != expected: + print("FAIL: the ledger is not deterministic — two builds of the same " + "controls differ, so a stale golden could never be detected") + return 1 + + t = data["totals"] + print(f"ownir validation ledger OK: {t['cases']} controls " + f"({t['accepted']} accept / {t['rejected']} reject), " + f"categories {t['by_category']}") + return 0 + + +if __name__ == "__main__": + if "--write" in sys.argv[1:]: + os.makedirs(os.path.dirname(FIXTURE), exist_ok=True) + with open(FIXTURE, "w", encoding="utf-8") as f: + f.write(_render_json(build())) + print(f"wrote {FIXTURE}") + raise SystemExit(0) + raise SystemExit(run())