From 5030314df57cd80e66d9338a00f6f40921c7956d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 17:22:56 +0000 Subject: [PATCH 01/11] test(ownir): freeze the BR-D1 strict-door acceptance language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #259 checkpoint 1, census step. The Python-authored oracle only — the Rust replay and the fixes it will force come next, deliberately after the measurement rather than alongside four hand-picked repairs. 77 controls over every BR-D1 rejection family, each with a neighbouring valid twin so the rejections are discriminating rather than passing against a loader that refuses everything. 10 accept / 67 reject across six categories. What is compared: accepted/rejected, and on rejection the CATEGORY. Not the message text — #259 asks for a matching error class/category, and Python funnels every rejection into one OwnIRError whose strings are a human-facing presentation aid. Byte-comparing them would freeze a debug surface as a cross-language contract. The taxonomy is deliberately small and derived from mechanisms, not messages: json / version / shape / vocabulary / identity / location. A category with no control that exercises it fails the ledger, so the taxonomy cannot outgrow its evidence. `reference` is absent on purpose: the sweep found no load-time referential constraint, and adding it on the strength of the issue text alone would invent a category nothing can exercise. BR-D1 fixes the ORDER of checks and notes it is observable through which error fires first, so six order-discrimination controls violate two rules at once. Each has exactly one correct category; a loader running its checks in a different order reports the other and fails. Found while building it: the ledger was non-deterministic on the first regeneration. `load()` takes a path and splices it into two of its messages, so a temporary filename rode into the golden. Normalized to a fixed token, and determinism is now asserted rather than assumed — a golden that changes every run cannot detect anything. Refs #259, #250. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CJF7MBi1ijU5m9cJVWgQsM --- tests/fixtures/ownir_validation.json | 1388 +++++++++++++++++++++++ tests/test_ownir_validation_fixtures.py | 532 +++++++++ 2 files changed, 1920 insertions(+) create mode 100644 tests/fixtures/ownir_validation.json create mode 100644 tests/test_ownir_validation_fixtures.py diff --git a/tests/fixtures/ownir_validation.json b/tests/fixtures/ownir_validation.json new file mode 100644 index 00000000..39b2159b --- /dev/null +++ b/tests/fixtures/ownir_validation.json @@ -0,0 +1,1388 @@ +{ + "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": "an identity field that is empty or duplicated", + "location": "a source coordinate violating the 1-based contract" + }, + "totals": { + "cases": 77, + "accepted": 10, + "rejected": 67, + "by_category": { + "accepted": 10, + "identity": 5, + "json": 2, + "location": 4, + "shape": 45, + "version": 7, + "vocabulary": 4 + } + }, + "cases": [ + { + "name": "accept-empty-object", + "why": "the minimal document: absent `ownir_version` means current, and every section is optional", + "section": "root", + "document": {}, + "verdict": "accept", + "category": null, + "message": "" + }, + { + "name": "accept-explicit-version", + "why": "the current version stated explicitly", + "section": "version", + "document": { + "ownir_version": 0 + }, + "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": [] + }, + "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 + } + ] + }, + "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": [ + {} + ] + } + ] + }, + "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" + } + ] + } + ] + }, + "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 + } + ] + } + ] + }, + "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" + } + ] + }, + "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" + } + ] + } + ] + }, + "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" + } + ] + } + ] + }, + "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", + "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 + ], + "verdict": "reject", + "category": "shape", + "message": "OwnIR root must be a JSON object" + }, + { + "name": "root-string", + "why": "nor a bare string", + "section": "root", + "document": "hello", + "verdict": "reject", + "category": "shape", + "message": " is not valid JSON: Expecting value: line 1 column 1 (char 0)" + }, + { + "name": "root-null", + "why": "nor null", + "section": "root", + "document": null, + "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 + }, + "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" + }, + "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 + }, + "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 + }, + "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 + }, + "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 + } + }, + "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 + ] + }, + "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 + } + ] + }, + "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" + ] + } + ] + }, + "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 + } + ] + } + ] + }, + "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" + } + ] + } + ] + }, + "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": "" + } + ] + } + ] + }, + "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 + } + ] + } + ] + }, + "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 + } + ] + } + ] + }, + "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 + } + ] + } + ] + }, + "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 + } + ] + } + ] + }, + "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 + } + ] + } + ] + }, + "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 + } + ] + } + ] + }, + "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 + } + ] + } + ] + }, + "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" + } + ] + } + ] + }, + "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 + } + }, + "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" + } + ] + }, + "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" + } + ] + }, + "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" + } + ] + }, + "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" + } + ] + }, + "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 + ] + } + ] + }, + "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 + ] + } + ] + }, + "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 + ] + } + ] + }, + "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 + ] + } + ] + }, + "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 + } + ] + }, + "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" + } + ] + }, + "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 + } + ] + }, + "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 + } + ] + }, + "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" + } + ] + }, + "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 + } + ] + }, + "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" + ] + } + ] + }, + "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" + ] + } + ] + }, + "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 + } + }, + "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 + ] + } + ] + }, + "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" + } + ] + }, + "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" + } + ] + }, + "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" + ] + } + ] + }, + "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 + } + ] + } + ] + }, + "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 + } + ] + } + ] + }, + "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 + ] + } + ] + } + ] + }, + "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" + } + ] + } + ] + }, + "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 + } + }, + "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 + } + ] + }, + "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" + } + ] + }, + "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" + ] + } + ] + }, + "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": "" + } + ] + } + ] + }, + "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 + } + ] + } + ] + }, + "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" + } + ] + } + ] + }, + "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" + } + ] + } + ] + }, + "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 + } + }, + "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", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocols": [ + { + "name": "P", + "states": [ + "a" + ], + "initial": "a", + "transitions": [] + }, + { + "name": "P", + "states": [ + "a" + ], + "initial": "a", + "transitions": [] + } + ] + }, + "verdict": "reject", + "category": "identity", + "message": "protocol 'P': 'opens' and 'closes' are both required" + }, + { + "name": "protocol-functions-not-array", + "why": "`protocol_functions` must be an array of objects", + "section": "protocols", + "document": { + "ownir_version": 0, + "protocol_functions": { + "a": 1 + } + }, + "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 + } + }, + "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" + } + ] + } + ] + }, + "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 + } + ], + "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", + "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 + } + ] + } + ] + }, + "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 + } + }, + "verdict": "reject", + "category": "shape", + "message": "OwnIR 'components' must be a JSON array of objects" + } + ] +} diff --git a/tests/test_ownir_validation_fixtures.py b/tests/test_ownir_validation_fixtures.py new file mode 100644 index 00000000..8b68ca14 --- /dev/null +++ b/tests/test_ownir_validation_fixtures.py @@ -0,0 +1,532 @@ +#!/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. + +## 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", "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": "an identity field that is empty or duplicated", + "location": "a source coordinate violating the 1-based contract", +} + + +def _c(name: str, section: str, why: str, document: Any, + category: str | None) -> dict[str, Any]: + """One control. `category` is None for a document that must be ACCEPTED.""" + assert category is None or category in CATEGORIES, f"{name}: bad category" + return {"name": name, "section": section, "why": why, + "document": document, "category": category} + + +def _svc(**kw: Any) -> dict[str, Any]: + base: dict[str, Any] = {"name": "S", "lifetime": "singleton"} + base.update(kw) + return base + + +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"), + + # ---- 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", + {"ownir_version": 0, "protocols": [ + {"name": "P", "states": ["a"], "initial": "a", "transitions": []}, + {"name": "P", "states": ["a"], "initial": "a", "transitions": []}]}, + "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"), + _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"), + ] + + +def _oracle(document: Any) -> 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 isinstance(document, str) and not document.strip().startswith('"'): + 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"]) + cases.append({ + "name": ctl["name"], + "why": ctl["why"], + "section": ctl["section"], + "document": ctl["document"], + "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()) From af6e7824eb37ae6b14e7cc2987319e7afa8a50a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 17:39:53 +0000 Subject: [PATCH 02/11] feat(ownir): make the strict door accept the same language as the reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #259 checkpoint 1, implementation. The census (5030314) opened at 12 Rust-only accepts; this closes them and makes the crate's long-standing claim to "mirror the acceptance of Python load exactly" true for the first time. Matrix over the 77-control ledger, all three failure rows required zero: python accept / rust accept : 10 python reject / rust reject : 67 (same category) python reject / rust ACCEPT : 0 (was 12) python accept / rust REJECT : 0 category mismatch : 0 (was 5) Four mechanisms, not twelve special cases: * SIX fields were not declared in the Rust model at all (`source_provenance`, `ignore_reason`, `sig`, `column`, `protocols`, `protocol_functions`), so they fell into serde(flatten) `extra` and escaped checking. serde is admirably strict about fields it has been told about. Each is declared with ITS OWN semantics — `resource` rejects null, the nullable-optionals accept it — not one blanket policy, since sharing a bug is not sharing a contract. * the 1-based column rule (#317): 0, negative, bool and string. * the closed `resource` vocabulary (IR4). * protocol-name identity. Errors are now typed. `OwnIrErrorKind` = Json | Version | Shape | Vocabulary | Identity | Location, one variant per mechanism a loader can reject on. The ledger fails if a declared category has no control exercising it, so the taxonomy cannot outgrow its evidence — which is why there is no `Reference` variant: the sweep found no load-time referential constraint. Category is compared, message text never is. The reference funnels everything into one OwnIRError whose strings are a presentation aid. Equally, the expected category is DECLARED by the ledger and confirmed by the oracle only as accept/reject — deriving it from Python's message would abandon message parity at the front door and rebuild it as regex parity at the back. The mechanism must not pick the category. A serde enum would reject `lifetime: "eternal"` perfectly well and call it Shape, when the contract is a closed vocabulary; a typed NonZeroU32 column would call a 0 Shape when the contract is a coordinate. So vocabulary/identity/location run in a gate over the RAW document before deserialization — which also gets the BR-D1 order right, where the version gate must precede shapes. Both doors keep their unknown-resource check. The strict door now rejects at load (cp1); the lowerer keeps its own (#294 OD-2) because the tolerant path bypasses `load()` entirely. This became load-bearing immediately: the bridge's `tolerant_unknown_kind` fixture was routing through `from_json`, so a strict door that enforces IR4 made the tolerant-door test unreachable. It now deserializes directly, as its real callers do. Seven mutations, one per mechanism plus one that only misclassifies a category while still rejecting — all caught. That last one is the guard against a decorative taxonomy. P-022's cp1 row corrected: it conflated the two doors, reading as though OD-2 satisfied cp1. Refs #259, #250. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CJF7MBi1ijU5m9cJVWgQsM --- docs/proposals/P-022-rust-core-migration.md | 2 +- rust/crates/own-bridge/tests/replay.rs | 15 +- rust/crates/own-ir/src/lib.rs | 477 +++++++++++++++++- rust/crates/own-ir/tests/roundtrip.rs | 2 +- rust/crates/own-ir/tests/validation_replay.rs | 239 +++++++++ tests/fixtures/ownir_validation.json | 79 ++- tests/test_ownir_validation_fixtures.py | 28 +- 7 files changed, 804 insertions(+), 38 deletions(-) create mode 100644 rust/crates/own-ir/tests/validation_replay.rs diff --git a/docs/proposals/P-022-rust-core-migration.md b/docs/proposals/P-022-rust-core-migration.md index cfec9b95..f6795788 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 | **complete** | strict-door (BR-D1) acceptance parity, measured rather than asserted: a 77-control Python-authored ledger (`tests/fixtures/ownir_validation.json`) replayed with zero Python, requiring **0** Rust-only accepts, **0** Rust-only rejects and **0** error-category mismatches. The sweep opened at 12 Rust-only accepts from four mechanisms — undeclared fields swallowed by `serde(flatten)`, the 1-based column rule, the closed `resource` vocabulary, and protocol-name identity. Errors carry a typed `OwnIrErrorKind` so the category is compared, never the message text. **Note the earlier status conflated two doors:** #294 OD-2 pins unknown-resource rejection on the *tolerant* path that bypasses `load()` entirely, and does not satisfy cp1 — both checks exist on purpose, and removing either reopens a different hole | | 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" | 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..3a3ad2a0 100644 --- a/rust/crates/own-ir/src/lib.rs +++ b/rust/crates/own-ir/src/lib.rs @@ -3,12 +3,23 @@ //! `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`) exactly — a claim that is now **measured**, not +//! asserted: `tests/validation_replay.rs` replays a 77-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 +//! That measurement was worth having. The same sentence used to sit here +//! unbacked, and a differential sweep found **twelve** documents the reference +//! refused and this crate accepted (#259 cp1) — mostly because a field the +//! model never declared cannot be checked by serde, however strict serde is +//! about the fields it *has* been told about. +//! +//! * **typed fields are the ones the strict door 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); +//! property `tests/roundtrip.rs` pins against the repo's `OwnIR` fixtures). +//! A field that Python *does* validate must be declared here, or it silently +//! falls into `extra` and escapes checking entirely; //! * 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 @@ -29,19 +40,96 @@ 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 — `tests/fixtures/ownir_validation.json` +/// fails if a declared category has no control exercising it, so the taxonomy +/// cannot outgrow its evidence. There is deliberately no `Reference` variant: +/// 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. +#[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, +} + +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", + } + } +} + +/// 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 {} +/// 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 +226,36 @@ 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 [`OwnIr::validate`] where it can answer `Location`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub column: Option, #[serde(flatten)] pub extra: Map, } @@ -316,6 +434,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,43 +487,342 @@ pub struct OwnIr { skip_serializing_if = "Option::is_none" )] pub functions: Option>, + /// Obligation-protocol declarations. Held as raw values: the reference + /// checks only that this is a LIST here and delegates each record to the + /// shared obligation parser, which is a separate surface with its own + /// vocabulary. What this crate owns from it is the identity invariant — + /// see [`OwnIr::validate`]. Record-level validity beyond that is NOT yet + /// mirrored; documented in the ledger rather than silently assumed. + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub protocols: Option>, + /// Per-method protocol facts. Same delegation as `protocols`: list shape + /// here, record parsing elsewhere. + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub protocol_functions: Option>, #[serde(flatten)] pub extra: Map, } +/// The checks whose **category** must not be decided by serde. +/// +/// Run against the raw document, before typed deserialization. A serde enum +/// would reject `lifetime: "eternal"` perfectly well — and report it as +/// [`OwnIrErrorKind::Shape`], when the contract violated is a closed +/// vocabulary. Likewise a `name: 7` is an identity failure in the reference, +/// not a type failure. Deserializing first and classifying after would make +/// accept/reject parity green while the taxonomy quietly lied. +/// +/// Ordered to follow BR-D1: `components` → `services` → `functions` → +/// `protocols`. +fn gate_semantics(obj: &Map) -> Result<(), OwnIrError> { + gate_components(obj)?; + gate_services(obj)?; + gate_params(obj)?; + gate_protocols(obj) +} + +/// `components[]` — resource shape, then its closed vocabulary, then columns. +fn gate_components(obj: &Map) -> Result<(), OwnIrError> { + for sub in obj + .get("components") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_object) + .filter_map(|c| c.get("subscriptions")) + .filter_map(Value::as_array) + .flatten() + .filter_map(Value::as_object) + { + // 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) — that one guards the tolerant path + // which bypasses this loader entirely. Two doors, not one duplicated + // check: removing either reopens a different hole. + // Shape before vocabulary, in that order: `resource` must BE a string + // before its value can be tested against the closed set. Checking the + // set first (and skipping a non-string) would let `{"resource": 7, + // "column": 0}` be reported as a column problem, which is the wrong + // contract and the wrong category. + match sub.get("resource") { + Some(Value::String(_)) | None => {} + Some(other) => { + return Err(OwnIrError::new( + OwnIrErrorKind::Shape, + format!("subscription 'resource' must be a string, got {other}"), + )) + } + } + if let Some(kind) = sub.get("resource").and_then(Value::as_str) { + if !KNOWN_RESOURCE_KINDS.contains(&kind) { + return Err(OwnIrError::new( + OwnIrErrorKind::Vocabulary, + format!( + "unknown resource kind {kind:?} — a new kind is a \ + vocabulary change that must bump OWNIR_VERSION" + ), + )); + } + } + check_column(sub.get("column"), "subscription")?; + } + Ok(()) +} + +/// `services[]` — name identity, then the DI lifetime vocabulary. +fn gate_services(obj: &Map) -> Result<(), OwnIrError> { + for svc in obj + .get("services") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_object) + { + // The service name is the identity the DI graph joins on: empty and + // non-string are the same defect class, and the reference reports both + // with one message. + match svc.get("name") { + Some(Value::String(n)) if !n.is_empty() => {} + None => {} + _ => { + return Err(OwnIrError::new( + OwnIrErrorKind::Identity, + "service 'name' must be a non-empty string", + )) + } + } + if let Some(v) = svc.get("lifetime") { + let known = v + .as_str() + .is_some_and(|l| ["scoped", "singleton", "transient"].contains(&l)); + if !known { + return Err(OwnIrError::new( + OwnIrErrorKind::Vocabulary, + format!( + "service 'lifetime' must be one of \ + [\"scoped\", \"singleton\", \"transient\"], got {v}" + ), + )); + } + } + } + + Ok(()) +} + +/// `functions[].params[]` — name identity, then the effect vocabulary. +fn gate_params(obj: &Map) -> Result<(), OwnIrError> { + for param in obj + .get("functions") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_object) + .filter_map(|f| f.get("params")) + .filter_map(Value::as_array) + .flatten() + .filter_map(Value::as_object) + { + match param.get("name") { + Some(Value::String(n)) if !n.is_empty() => {} + None => {} + _ => { + return Err(OwnIrError::new( + OwnIrErrorKind::Identity, + "parameter 'name' must be a non-empty string", + )) + } + } + if let Some(v) = param.get("effect") { + let known = v.is_null() + || v.as_str() + .is_some_and(|e| ["borrow", "borrow_mut", "consume", "plain"].contains(&e)); + if !known { + return Err(OwnIrError::new( + OwnIrErrorKind::Vocabulary, + format!( + "parameter 'effect' must be one of \ + [\"borrow\", \"borrow_mut\", \"consume\", \"plain\"], got {v}" + ), + )); + } + } + } + + Ok(()) +} + +/// `protocols[]` — the identity invariant two individually valid records can +/// only violate together. +fn gate_protocols(obj: &Map) -> Result<(), OwnIrError> { + let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); + for proto in obj + .get("protocols") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_object) + { + if let Some(name) = proto.get("name").and_then(Value::as_str) { + if !seen.insert(name) { + return Err(OwnIrError::new( + OwnIrErrorKind::Identity, + format!( + "duplicate protocol name '{name}' — protocol names are \ + the identity findings map back by and must be unique" + ), + )); + } + } + } + Ok(()) +} + +/// The 1-based source-column contract (#317). +/// +/// A column is a positive integer or it is absent. `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. +/// `true` is rejected explicitly — the reference guards the same trap because a +/// Python `bool` is an `int`, and accepting it would fabricate column 1. +fn check_column(value: Option<&Value>, where_: &str) -> Result<(), OwnIrError> { + let Some(v) = value else { return Ok(()) }; + if v.is_null() { + return Ok(()); + } + // `Bool` is called out rather than folded into the wildcard: it is the + // trap the reference guards explicitly (a Python `bool` is an `int`, so + // `True` would otherwise be read as column 1 — a fabricated coordinate). + let ok = match v { + Value::Number(n) => n.as_i64().is_some_and(|i| i >= 1), + _ => false, + }; + if ok { + return Ok(()); + } + Err(OwnIrError::new( + OwnIrErrorKind::Location, + format!("{where_} 'column' must be a 1-based integer or absent, got {v}"), + )) +} + 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). Mirrors the acceptance of Python `ownlang.ownir.load`, + /// including the observable order: JSON → root-is-object → version gate → + /// semantic gate → typed shapes. + /// + /// 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}")))?; + // BR-D1 fixes the ORDER, and the spec notes it "is observable through + // which error fires first". A single `from_str::` would collapse + // that: serde would reject a malformed `components` before anything + // could look at `ownir_version`, reporting Shape where the reference + // reports Version. So the gate runs on an untyped value first. + 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", + )); + }; + Self::gate_version(obj)?; + gate_semantics(obj)?; + let doc: Self = serde_json::from_value(raw).map_err(|e| { + OwnIrError::new( + OwnIrErrorKind::Shape, + format!("OwnIR facts are not valid: {e}"), + ) + })?; doc.validate()?; Ok(doc) } - /// The checks serde's typing cannot express: the version gate and the - /// non-empty-identity rules. + /// The version gate, run against the untyped document so it precedes every + /// shape check exactly as BR-D1 requires. + /// + /// An absent field means the current version (the only producers that omit + /// it predate versioning). A present `bool` is rejected explicitly: JSON + /// has a real boolean type, but the reference guards the same trap because + /// `True` is an `int` in Python, and accepting `true` as v1 here would let + /// a document through that the reference refuses. + fn gate_version(obj: &Map) -> Result<(), OwnIrError> { + let Some(v) = obj.get("ownir_version") else { + return Ok(()); + }; + // A `Bool` deliberately falls through to `None` with everything else: + // the reference guards it because a Python `bool` is an `int`, and + // accepting `true` here would read as schema v1. + let ver = match v { + Value::Number(n) if n.is_i64() => n.as_i64(), + _ => None, + }; + let Some(ver) = ver else { + return Err(OwnIrError::new( + OwnIrErrorKind::Version, + format!("OwnIR 'ownir_version' must be an integer, got {v}"), + )); + }; + if ver != OWNIR_VERSION { + return Err(OwnIrError::new( + OwnIrErrorKind::Version, + 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." + ), + )); + } + Ok(()) + } + + /// The checks serde's typing cannot express, for a value built directly + /// rather than loaded. + /// + /// The full semantic gate (closed vocabularies, the 1-based column rule, + /// protocol identity) runs inside [`OwnIr::from_json`] against the **raw** + /// document, because getting the error *category* right requires checking + /// before serde deserializes — see [`gate_semantics`]. `from_json` is the + /// strict door; this is the residue a typed value can still violate. /// /// # Errors /// [`OwnIrError`] on a schema-version mismatch or an empty identity field. 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." - ))); + return Err(OwnIrError::new( + OwnIrErrorKind::Version, + 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(), + return Err(OwnIrError::new( + OwnIrErrorKind::Identity, + "service 'name' must be a non-empty string", )); } } @@ -406,8 +833,9 @@ impl OwnIr { .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(), + return Err(OwnIrError::new( + OwnIrErrorKind::Identity, + "parameter 'name' must be a non-empty string", )); } } @@ -421,6 +849,7 @@ impl OwnIr { /// [`OwnIrError`] 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}"))) + serde_json::to_value(self) + .map_err(|e| OwnIrError::new(OwnIrErrorKind::Shape, format!("serialize failed: {e}"))) } } 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..8841870b --- /dev/null +++ b/rust/crates/own-ir/tests/validation_replay.rs @@ -0,0 +1,239 @@ +//! 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)); + } + } + } + + assert_eq!( + m.rust_only_accept, + 0, + "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:\n {}\nmatrix: {m:?}", + permissive.join("\n ") + ); + assert_eq!( + m.rust_only_reject, + 0, + "over-strictness — not a hole, but facts the reference analyses today \ + would stop being analysable after cutover:\n {}\nmatrix: {m:?}", + over_strict.join("\n ") + ); + assert_eq!( + m.kind_mismatch, + 0, + "both loaders reject, but disagree about WHY. Accept/reject parity can \ + be green while the taxonomy is decorative — this is the assertion that \ + stops that:\n {}\nmatrix: {m:?}", + mismatched.join("\n ") + ); + assert_eq!( + m.agreed_accept.saturating_add(m.agreed_reject), + cases(&root).len(), + "every control must land in an agreed row: {m:?}" + ); +} + +#[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. + for kind in [ + OwnIrErrorKind::Json, + OwnIrErrorKind::Version, + OwnIrErrorKind::Shape, + OwnIrErrorKind::Vocabulary, + OwnIrErrorKind::Identity, + OwnIrErrorKind::Location, + ] { + 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); + } +} diff --git a/tests/fixtures/ownir_validation.json b/tests/fixtures/ownir_validation.json index 39b2159b..e12c42f5 100644 --- a/tests/fixtures/ownir_validation.json +++ b/tests/fixtures/ownir_validation.json @@ -29,6 +29,7 @@ "why": "the minimal document: absent `ownir_version` means current, and every section is optional", "section": "root", "document": {}, + "raw": false, "verdict": "accept", "category": null, "message": "" @@ -40,6 +41,7 @@ "document": { "ownir_version": 0 }, + "raw": false, "verdict": "accept", "category": null, "message": "" @@ -57,6 +59,7 @@ "protocols": [], "protocol_functions": [] }, + "raw": false, "verdict": "accept", "category": null, "message": "" @@ -73,6 +76,7 @@ } ] }, + "raw": false, "verdict": "accept", "category": null, "message": "" @@ -91,6 +95,7 @@ } ] }, + "raw": false, "verdict": "accept", "category": null, "message": "" @@ -132,6 +137,7 @@ } ] }, + "raw": false, "verdict": "accept", "category": null, "message": "" @@ -152,6 +158,7 @@ } ] }, + "raw": false, "verdict": "accept", "category": null, "message": "" @@ -177,6 +184,7 @@ } ] }, + "raw": false, "verdict": "accept", "category": null, "message": "" @@ -210,6 +218,7 @@ } ] }, + "raw": false, "verdict": "accept", "category": null, "message": "" @@ -230,6 +239,7 @@ } ] }, + "raw": false, "verdict": "accept", "category": null, "message": "" @@ -239,6 +249,7 @@ "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)" @@ -252,6 +263,7 @@ 2, 3 ], + "raw": false, "verdict": "reject", "category": "shape", "message": "OwnIR root must be a JSON object" @@ -261,15 +273,17 @@ "why": "nor a bare string", "section": "root", "document": "hello", + "raw": false, "verdict": "reject", "category": "shape", - "message": " is not valid JSON: Expecting value: line 1 column 1 (char 0)" + "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" @@ -281,6 +295,7 @@ "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." @@ -292,6 +307,7 @@ "document": { "ownir_version": "0" }, + "raw": false, "verdict": "reject", "category": "version", "message": "OwnIR 'ownir_version' must be an integer, got '0'" @@ -303,6 +319,7 @@ "document": { "ownir_version": true }, + "raw": false, "verdict": "reject", "category": "version", "message": "OwnIR 'ownir_version' must be an integer, got True" @@ -314,6 +331,7 @@ "document": { "ownir_version": 0.0 }, + "raw": false, "verdict": "reject", "category": "version", "message": "OwnIR 'ownir_version' must be an integer, got 0.0" @@ -325,6 +343,7 @@ "document": { "ownir_version": null }, + "raw": false, "verdict": "reject", "category": "version", "message": "OwnIR 'ownir_version' must be an integer, got None" @@ -339,6 +358,7 @@ "a": 1 } }, + "raw": false, "verdict": "reject", "category": "shape", "message": "OwnIR 'components' must be a JSON array of objects" @@ -354,6 +374,7 @@ 2 ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "OwnIR 'components' must be a JSON array of objects" @@ -370,6 +391,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "each component's 'subscriptions' must be objects" @@ -388,6 +410,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "each component's 'subscriptions' must be objects" @@ -408,6 +431,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "subscription 'resource' must be a string, got 7" @@ -428,6 +452,7 @@ } ] }, + "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)" @@ -448,6 +473,7 @@ } ] }, + "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)" @@ -468,6 +494,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "subscription 'type' must be a string, got 7" @@ -488,6 +515,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "subscription 'source_type' must be a string, got 7" @@ -508,6 +536,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "subscription 'source_provenance' must be a string, got 7" @@ -528,6 +557,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "subscription 'ignore_reason' must be a string, got 7" @@ -548,6 +578,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "location", "message": "subscription 'column' must be a 1-based integer or absent, got 0" @@ -568,6 +599,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "location", "message": "subscription 'column' must be a 1-based integer or absent, got -1" @@ -588,6 +620,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "location", "message": "subscription 'column' must be a 1-based integer or absent, got True" @@ -608,6 +641,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "location", "message": "subscription 'column' must be a 1-based integer or absent, got '3'" @@ -622,6 +656,7 @@ "a": 1 } }, + "raw": false, "verdict": "reject", "category": "shape", "message": "OwnIR 'services' must be a JSON array of objects" @@ -639,6 +674,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "vocabulary", "message": "service 'lifetime' must be one of ['scoped', 'singleton', 'transient'], got 'eternal'" @@ -656,6 +692,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "identity", "message": "service 'name' must be a non-empty string" @@ -673,6 +710,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "identity", "message": "service 'name' must be a non-empty string" @@ -691,6 +729,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "service 'deps' must be an array of strings" @@ -711,6 +750,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "service 'deps' must be an array of strings" @@ -731,6 +771,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "service 'weak_deps' must be an array of strings" @@ -751,6 +792,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "service 'root_resolves' must be an array of strings" @@ -771,6 +813,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "service 'scope_cached' must be an array of strings" @@ -789,6 +832,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "service 'file' must be a string" @@ -807,6 +851,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "service 'line' must be an integer" @@ -825,6 +870,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "service 'line' must be an integer" @@ -843,6 +889,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "service 'ctor_file' must be a string" @@ -861,6 +908,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "service 'ctor_line' must be an integer" @@ -879,6 +927,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "service 'ctor_type' must be a string" @@ -899,6 +948,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "service 'root_resolve_sites' must be an array of {type:str, file:str, line:int} objects" @@ -919,6 +969,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "service 'scope_cache_sites' must be an array of {type:str, file:str, line:int} objects" @@ -933,6 +984,7 @@ "a": 1 } }, + "raw": false, "verdict": "reject", "category": "shape", "message": "OwnIR 'effects' must be a JSON array of objects" @@ -951,6 +1003,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "effect 'deps' must be an array of strings" @@ -967,6 +1020,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "effect 'io' must be a boolean, got 'yes'" @@ -983,6 +1037,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "effect 'line' must be an integer" @@ -1001,6 +1056,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "effect 'bindings' must be a JSON array of objects" @@ -1021,6 +1077,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "binding 'name' must be a string" @@ -1041,6 +1098,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "binding 'init' must be a string" @@ -1063,6 +1121,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "binding 'refs' must be an array of strings" @@ -1083,6 +1142,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "binding 'line' must be an integer" @@ -1097,6 +1157,7 @@ "a": 1 } }, + "raw": false, "verdict": "reject", "category": "shape", "message": "OwnIR 'functions' must be a JSON array of objects" @@ -1113,6 +1174,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "function 'sig' must be a string, got 7" @@ -1129,6 +1191,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "a function's 'params' must be a JSON array of objects" @@ -1147,6 +1210,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "a function's 'params' must be a JSON array of objects" @@ -1167,6 +1231,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "identity", "message": "parameter 'name' must be a non-empty string, got ''" @@ -1187,6 +1252,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "identity", "message": "parameter 'name' must be a non-empty string, got 7" @@ -1208,6 +1274,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "parameter 'line' must be an integer, got '3'" @@ -1229,6 +1296,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "vocabulary", "message": "parameter 'effect' must be one of ['borrow', 'borrow_mut', 'consume', 'plain'], got 'teleport'" @@ -1243,6 +1311,7 @@ "a": 1 } }, + "raw": false, "verdict": "reject", "category": "shape", "message": "OwnIR 'protocols' must be a JSON array of objects" @@ -1272,6 +1341,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "identity", "message": "protocol 'P': 'opens' and 'closes' are both required" @@ -1286,6 +1356,7 @@ "a": 1 } }, + "raw": false, "verdict": "reject", "category": "shape", "message": "OwnIR 'protocol_functions' must be a JSON array of objects" @@ -1300,6 +1371,7 @@ "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." @@ -1320,6 +1392,7 @@ } ] }, + "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." @@ -1333,6 +1406,7 @@ "ownir_version": 99 } ], + "raw": false, "verdict": "reject", "category": "shape", "message": "OwnIR root must be a JSON object" @@ -1342,6 +1416,7 @@ "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)" @@ -1363,6 +1438,7 @@ } ] }, + "raw": false, "verdict": "reject", "category": "shape", "message": "subscription 'resource' must be a string, got 7" @@ -1380,6 +1456,7 @@ "b": 2 } }, + "raw": false, "verdict": "reject", "category": "shape", "message": "OwnIR 'components' must be a JSON array of objects" diff --git a/tests/test_ownir_validation_fixtures.py b/tests/test_ownir_validation_fixtures.py index 8b68ca14..87141a43 100644 --- a/tests/test_ownir_validation_fixtures.py +++ b/tests/test_ownir_validation_fixtures.py @@ -64,7 +64,8 @@ SCHEMA_VERSION = 1 ROOT_KEYS = {"comment", "schema_version", "categories", "totals", "cases"} -CASE_KEYS = {"name", "why", "section", "document", "verdict", "category", "message"} +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 @@ -84,11 +85,19 @@ def _c(name: str, section: str, why: str, document: Any, - category: str | None) -> dict[str, Any]: - """One control. `category` is None for a document that must be ACCEPTED.""" + 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} + "document": document, "category": category, "raw": raw} def _svc(**kw: Any) -> dict[str, Any]: @@ -155,7 +164,7 @@ def _controls() -> list[dict[str, Any]]: # ---- json ------------------------------------------------------------ _c("json-not-parseable", "json", "a truncated document is not JSON at all — rejected before any " - "shape check can run", "{not json", "json"), + "shape check can run", "{not json", "json", raw=True), # ---- root shape ------------------------------------------------------ _c("root-array", "root", "the root must be an object, not an array", @@ -359,7 +368,7 @@ def _controls() -> list[dict[str, Any]]: [{"ownir_version": 99}], "shape"), _c("order-json-before-everything", "order", "an unparseable document cannot reach any structural check", - "{\"ownir_version\": 99", "json"), + "{\"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", @@ -374,7 +383,7 @@ def _controls() -> list[dict[str, Any]]: ] -def _oracle(document: Any) -> tuple[str, str]: +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 @@ -391,7 +400,7 @@ def _oracle(document: Any) -> tuple[str, str]: path = os.path.join(directory, "facts.ownir.json") try: with open(path, "w", encoding="utf-8") as f: - if isinstance(document, str) and not document.strip().startswith('"'): + if raw: f.write(document) else: json.dump(document, f) @@ -409,12 +418,13 @@ def _oracle(document: Any) -> tuple[str, str]: def build() -> dict[str, Any]: cases: list[dict[str, Any]] = [] for ctl in _controls(): - verdict, message = _oracle(ctl["document"]) + 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: From 8096fff62b39a016f1cf395a90e4755726cd5ae9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 17:46:45 +0000 Subject: [PATCH 03/11] =?UTF-8?q?docs(P-022):=20cp1=20is=20partial=20?= =?UTF-8?q?=E2=80=94=20the=20ledger=20had=20the=20same=20blind=20spots=20a?= =?UTF-8?q?s=20the=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review found seven divergences the 77-control ledger cannot see, so the 0/0/0 matrix was true only over the controls I wrote. Each verified against the reference before recording: absent service.lifetime python reject / rust ACCEPT params[].column: 0 (+ flow body) python reject / rust ACCEPT protocols: [7], malformed records python reject / rust ACCEPT column > i64::MAX python ACCEPT / rust reject <- over-strict components shape vs later service semantics Shape vs Vocabulary lifetime-before-name order reference checks lifetime first protocol-duplicate-name control rejects on record shape, not the duplicate Two are structural rather than missing branches. Ordering: BR-D1 interleaves shape-then-semantics per section. This port runs every semantic check before deserialization, which got the version gate right and everything after it wrong — `{components:{}, services:[{lifetime:"bad"}]}` answers Vocabulary where the reference answers Shape. Fixing it means interleaving per section, not adding another check. Range: Python integers are unbounded and `_check_column` accepts any positive one. `as_i64()` returns None above i64::MAX, so a document the reference accepts is rejected. The over-strictness row was never actually zero. The honest lesson is about the ledger, not the port. I wrote both, so a gap in my reading of the contract produced a matching gap in each, and the matrix read green — `_svc` always set `lifetime`, so no control could ever omit it. A same-author oracle measures the author's understanding, not the contract. The reviewers found these precisely because they were not the author. The claim is corrected before the fixes land rather than after, so the PR does not sit on a false "complete" while the work is done. Refs #259, #250. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CJF7MBi1ijU5m9cJVWgQsM --- docs/proposals/P-022-rust-core-migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/proposals/P-022-rust-core-migration.md b/docs/proposals/P-022-rust-core-migration.md index f6795788..35150c6a 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 | **complete** | strict-door (BR-D1) acceptance parity, measured rather than asserted: a 77-control Python-authored ledger (`tests/fixtures/ownir_validation.json`) replayed with zero Python, requiring **0** Rust-only accepts, **0** Rust-only rejects and **0** error-category mismatches. The sweep opened at 12 Rust-only accepts from four mechanisms — undeclared fields swallowed by `serde(flatten)`, the 1-based column rule, the closed `resource` vocabulary, and protocol-name identity. Errors carry a typed `OwnIrErrorKind` so the category is compared, never the message text. **Note the earlier status conflated two doors:** #294 OD-2 pins unknown-resource rejection on the *tolerant* path that bypasses `load()` entirely, and does not satisfy cp1 — both checks exist on purpose, and removing either reopens a different hole | +| 1 — typed OwnIR validation | **partial — do not read as done** | A 77-control Python-authored ledger (`tests/fixtures/ownir_validation.json`) is replayed with zero Python and reports 0/0/0 across the three failure rows — but **that matrix is only as wide as the ledger**, and independent review found **seven** real divergences it cannot see: absent `service.lifetime` (Python rejects, Rust accepts), `params[].column: 0` and nested flow-body columns (same), `protocols: [7]` and other malformed protocol records (same), a column above `i64::MAX` (Python accepts, Rust **rejects** — the over-strictness row is not actually zero), the `lifetime`-before-`name` order, and section ordering: BR-D1 interleaves *shape then semantics per section*, while this port runs all semantics before all shapes, so `{components: {}, services: [{lifetime: "bad"}]}` answers `Vocabulary` where the reference answers `Shape`. The `protocol-duplicate-name` control is also not evidence — the reference rejects those records for missing `opens`/`closes`, so the Rust duplicate-name gate is unverified. **The lesson is the ledger's, not the port's:** both were written by the same author, so a blind spot produced a matching hole in each and the matrix read green. #294 OD-2 remains a separate tolerant-door concern and still does not satisfy cp1 | | 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" | From cc2084b4ec001af9803bc80b8a8e500746c1050e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 18:56:16 +0000 Subject: [PATCH 04/11] test(ownir): re-census the strict door against BR-D1, not against the port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first census reached 0/0/0 over 77 controls and was recorded as complete. Review then found seven divergences it could not express. The cause was structural, not 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 is `_svc()`, which always supplied `lifetime` — so no control could omit it, and the reference rejects an absent lifetime while the port accepts it. Mutation testing proved the implementation against the ledger. Nothing proved the ledger against the contract. So this round is derived by reading `load()` and the shared obligation parser line by line, before looking at what the port does with them. 191 controls, up from 77. New families: - required fields ABSENT (the `_svc()` blind spot), plus `_proto()` and `_pfn()` helpers that build deliberately incomplete records - ordering WITHIN a section: lifetime-before-name, sig-before-body, body-columns-before-params, param name/line/column/effect - ordering ACROSS sections: BR-D1 interleaves shape and semantics per section, so a components SHAPE failure outranks a services VOCABULARY failure. Six controls chain every section boundary - flow columns, recursive through then/else/body to three levels - params[].column, a separate reference call site the port lacked - the protocol acceptance grammar delegated to obligations.py: parse_protocol / parse_matcher / parse_events / parse_method, with valid twins first — the old duplicate-name control used records with no opens/closes, so it never reached the duplicate branch it claimed to test - explicit null on every section and scalar (present null is not absent), and the three places where null IS accepted - integer boundaries pinned at i64::MAX, where both sides agree Measured RED at this commit, all three rows in one reading: agreed accept 29 agreed reject 95 python reject / RUST ACCEPT 58 python accept / rust reject 0 category mismatch 9 The nine mismatches are the architecture, not nine oversights: absent required fields surface as serde `Shape` instead of the contract's vocabulary/identity, and "all semantics, then all shapes" inverts precedence both within and across sections. The replay now reports all three failure rows in one assertion instead of three sequential ones. Sequential asserts show only the first non-empty row, which is how a 58-and-9 census would have read as a permissiveness problem alone. Integer width is measured and deliberately NOT covered: Python ints are unbounded and every line/column field accepts values past 64 bits, so Rust is over-strict across seven field families. Widening Rust to arbitrary precision to honour a nine-quintillion source coordinate is the wrong repair; the boundary is pinned where both agree and the range above it is a separate Python-first defensive limit. No production code changed in this commit. Refs #250, #259. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CJF7MBi1ijU5m9cJVWgQsM --- rust/crates/own-ir/tests/validation_replay.rs | 58 +- tests/fixtures/ownir_validation.json | 2691 ++++++++++++++++- tests/test_ownir_validation_fixtures.py | 631 +++- 3 files changed, 3331 insertions(+), 49 deletions(-) diff --git a/rust/crates/own-ir/tests/validation_replay.rs b/rust/crates/own-ir/tests/validation_replay.rs index 8841870b..d46226d1 100644 --- a/rust/crates/own-ir/tests/validation_replay.rs +++ b/rust/crates/own-ir/tests/validation_replay.rs @@ -128,28 +128,42 @@ fn the_two_loaders_accept_the_same_language() { } } - assert_eq!( - m.rust_only_accept, - 0, - "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:\n {}\nmatrix: {m:?}", - permissive.join("\n ") - ); - assert_eq!( - m.rust_only_reject, - 0, - "over-strictness — not a hole, but facts the reference analyses today \ - would stop being analysable after cutover:\n {}\nmatrix: {m:?}", - over_strict.join("\n ") - ); - assert_eq!( - m.kind_mismatch, - 0, - "both loaders reject, but disagree about WHY. Accept/reject parity can \ - be green while the taxonomy is decorative — this is the assertion that \ - stops that:\n {}\nmatrix: {m:?}", - mismatched.join("\n ") + // 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 mut report = String::new(); + if m.rust_only_accept > 0 { + report.push_str(&format!( + "\n\nCRITICAL permissiveness ({}) — the strict door is the gate over \ + untrusted extractor output, and these documents are refused by the \ + reference but analysed by the port:\n {}", + m.rust_only_accept, + permissive.join("\n ") + )); + } + if m.rust_only_reject > 0 { + report.push_str(&format!( + "\n\nover-strictness ({}) — not a hole, but facts the reference \ + analyses today would stop being analysable after cutover:\n {}", + m.rust_only_reject, + over_strict.join("\n ") + )); + } + if m.kind_mismatch > 0 { + report.push_str(&format!( + "\n\ncategory mismatch ({}) — both loaders reject, but disagree \ + about WHY. Accept/reject parity can be green while the taxonomy is \ + decorative; this is what stops that:\n {}", + m.kind_mismatch, + mismatched.join("\n ") + )); + } + 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), diff --git a/tests/fixtures/ownir_validation.json b/tests/fixtures/ownir_validation.json index e12c42f5..db550178 100644 --- a/tests/fixtures/ownir_validation.json +++ b/tests/fixtures/ownir_validation.json @@ -6,21 +6,21 @@ "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": "an identity field that is empty or duplicated", + "identity": "a name slot — empty, mistyped, or duplicated", "location": "a source coordinate violating the 1-based contract" }, "totals": { - "cases": 77, - "accepted": 10, - "rejected": 67, + "cases": 191, + "accepted": 29, + "rejected": 162, "by_category": { - "accepted": 10, - "identity": 5, + "accepted": 29, + "identity": 22, "json": 2, - "location": 4, - "shape": 45, + "location": 20, + "shape": 93, "version": 7, - "vocabulary": 4 + "vocabulary": 18 } }, "cases": [ @@ -1318,33 +1318,43 @@ }, { "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", + "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", - "states": [ - "a" - ], - "initial": "a", - "transitions": [] + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + } }, { "name": "P", - "states": [ - "a" - ], - "initial": "a", - "transitions": [] + "opens": { + "kind": "assign", + "target": "flag", + "value": true + }, + "closes": { + "kind": "assign", + "target": "flag", + "value": false + } } ] }, "raw": false, "verdict": "reject", "category": "identity", - "message": "protocol 'P': 'opens' and 'closes' are both required" + "message": "duplicate protocol name 'P' — protocol names are the identity findings map back by and must be unique" }, { "name": "protocol-functions-not-array", @@ -1460,6 +1470,2645 @@ "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. Categorised `shape` for want of a better home — every type here is correct, so this is the taxonomy's known imprecision, not a fit", + "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": "shape", + "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 taxonomy strain", + "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": "shape", + "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": "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 index 87141a43..f12dde5a 100644 --- a/tests/test_ownir_validation_fixtures.py +++ b/tests/test_ownir_validation_fixtures.py @@ -15,6 +15,70 @@ 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 recorded as `shape` for want of a better home. + Its types are all correct — it is a *well-formedness* rule, and the six- + category taxonomy has no variant for it. That is a known imprecision, written + down rather than smoothed over; a seventh category is not being invented + unilaterally to hide it. + +## 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 @@ -79,10 +143,15 @@ "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": "an identity field that is empty or duplicated", + "identity": "a name slot — empty, mistyped, or duplicated", "location": "a source coordinate violating the 1-based contract", } +# 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]: @@ -101,11 +170,61 @@ def _c(name: str, section: str, why: str, document: Any, 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. @@ -339,11 +458,12 @@ def _controls() -> list[dict[str, Any]]: {"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", - {"ownir_version": 0, "protocols": [ - {"name": "P", "states": ["a"], "initial": "a", "transitions": []}, - {"name": "P", "states": ["a"], "initial": "a", "transitions": []}]}, - "identity"), + "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"), @@ -380,6 +500,505 @@ def _controls() -> list[dict[str, Any]]: "`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. Categorised " + "`shape` for want of a better home — every type here is correct, so " + "this is the taxonomy's known imprecision, not a fit", + {"ownir_version": 0, "protocols": [_proto(exit_barriers=False)]}, + "shape"), + _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 taxonomy strain", + {"ownir_version": 0, "protocols": [_proto( + barriers=[{"kind": "assign", "target": "flag", "value": True}])]}, + "shape"), + _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"), ] From e5f52e852f761c405df7a388ba78997501ccb596 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 19:09:23 +0000 Subject: [PATCH 05/11] feat(ownir): make the strict door a section-local validator, not serde MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the 58 permissive documents and 9 category mismatches the re-census opened. The fix is architectural: no arrangement of the previous design could have passed. The previous door ran `version gate -> all semantic gates -> serde for all shapes`. That is a third check order, matching neither implementation. BR-D1 validates each section COMPLETELY before the next begins, interleaving shape and semantics inside it — so a `components` shape failure outranks a `services` vocabulary failure, and within a service `lifetime` outranks `name`. Hoisting semantics in front of serde gets section-local controls right and every ordering control wrong. So the strict door is now a sequential validator over the raw document: own-ir/src/strict.rs primitives + one function per section, applied in the reference's order own-ir/src/protocol.rs the obligation ACCEPTANCE grammar Not 47 transcribed `if`s — eight primitives, each encoding one of Python's access idioms, and section validators that apply them: objects / list d.get(k, []) as a container name_slot isinstance(v, str) and v — a value facts join on optional_string `is not None and not isinstance` — null tolerated defaulted_string isinstance(d.get(k, "?"), str) — null rejected defaulted_int int and not bool string_array list of str column the 1-based contract (#317), recursive over flow sites the {type, file, line} record The two string primitives are the place a single "policy for optional fields" would be silently wrong: `resource` rejects an explicit null and `source_provenance` accepts it, because the reference writes one as a defaulted isinstance and the other as `is not None and ...`. serde is now the CONSTRUCTOR, not the arbiter. Once the validator accepts, a serde failure means a rule lives in the model rather than the validator — its category and its ordering would both be accidental. That is marked with a sentinel and asserted against by `no_control_escapes_into_serde`, which is not decoration: five of the 27 mutations below are caught by it ALONE, because the model still enforced the rule while the validator no longer did. `OwnIr::validate` is now `to_value` + the same validator. It costs a round-trip and buys single-copy-of-the-law — the property whose absence already produced a false "mutation survived" in this PR, when a planted mutation hit one copy of a duplicated check and the other caught it. Protocol scope, exactly as agreed: `parse_protocol`, `parse_matcher`, `parse_events`, `parse_method` — what the door ACCEPTS. Not the lattice, the walker, matching, or verdicts. `protocols` and `protocol_functions` stay raw `Value`s with pure validation beside them, because nothing consumes a typed representation yet. Two of the grammar's rules ("can never fire", "barrier equals opens") are well-formedness rather than shape; they are recorded as `shape` with the taxonomy strain written down rather than a seventh category invented unilaterally. Final matrix over 191 controls: agreed accept 29 agreed reject 162 python reject / rust accept 0 python accept / rust reject 0 category mismatch 0 27 mutations, all caught — one per mechanism, plus three that only change a category while still rejecting, plus two that only change ORDER. The order mutations matter most: they are the ones the previous architecture could not have failed. Also fixed: `is_none_or` needs Rust 1.82 and the workspace MSRV is 1.74; three `private_intra_doc_links` of the same class #324 hit; and the replay now reports all three failure rows in one assertion, since sequential asserts showed only the first and would have made this a two-round discovery. Still open and deliberately not closed here: Python integers are unbounded, so every line/column field accepts values past 64 bits and Rust is over-strict above i64::MAX across seven field families. The ledger pins the boundary where both agree. The range above it is a Python-first defensive limit, not a reason to thread arbitrary-precision integers through own-ir and the bridge. Refs #250, #259. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CJF7MBi1ijU5m9cJVWgQsM --- docs/proposals/P-022-rust-core-migration.md | 8 +- rust/crates/own-ir/src/lib.rs | 380 +++---------- rust/crates/own-ir/src/protocol.rs | 332 ++++++++++++ rust/crates/own-ir/src/strict.rs | 499 ++++++++++++++++++ rust/crates/own-ir/tests/validation_replay.rs | 81 ++- 5 files changed, 964 insertions(+), 336 deletions(-) create mode 100644 rust/crates/own-ir/src/protocol.rs create mode 100644 rust/crates/own-ir/src/strict.rs diff --git a/docs/proposals/P-022-rust-core-migration.md b/docs/proposals/P-022-rust-core-migration.md index 35150c6a..26cfd5f9 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 — do not read as done** | A 77-control Python-authored ledger (`tests/fixtures/ownir_validation.json`) is replayed with zero Python and reports 0/0/0 across the three failure rows — but **that matrix is only as wide as the ledger**, and independent review found **seven** real divergences it cannot see: absent `service.lifetime` (Python rejects, Rust accepts), `params[].column: 0` and nested flow-body columns (same), `protocols: [7]` and other malformed protocol records (same), a column above `i64::MAX` (Python accepts, Rust **rejects** — the over-strictness row is not actually zero), the `lifetime`-before-`name` order, and section ordering: BR-D1 interleaves *shape then semantics per section*, while this port runs all semantics before all shapes, so `{components: {}, services: [{lifetime: "bad"}]}` answers `Vocabulary` where the reference answers `Shape`. The `protocol-duplicate-name` control is also not evidence — the reference rejects those records for missing `opens`/`closes`, so the Rust duplicate-name gate is unverified. **The lesson is the ledger's, not the port's:** both were written by the same author, so a blind spot produced a matching hole in each and the matrix read green. #294 OD-2 remains a separate tolerant-door concern and still does not satisfy cp1 | +| 1 — typed OwnIR validation | **complete** | Two censuses, and the second is the point. The first froze 77 controls, closed twelve permissive documents and read 0/0/0 — then independent 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: **191 controls**, which opened a further **58** permissive documents and **9** category mismatches. Closing them was architectural, not incremental — the strict door is now a sequential validator over the raw document (`own-ir/src/strict.rs`) that reproduces BR-D1's interleaving of shape and semantics *per section, in declaration order*. `serde` is the typed constructor, no longer the arbiter, and a document it rejects after validation is reported as a hole in the validator and asserted against. The obligation **acceptance grammar** (`parse_protocol`/`parse_matcher`/`parse_events`/`parse_method`) is ported in `own-ir/src/protocol.rs`; protocol *analysis* is not, and is not part of what the door accepts. Final matrix 29/162 agreed, 0/0/0 across the three failure rows; 27 mutations each caught, five of them only by the validator-hole guard. **One measured divergence stays open and is deliberately not closed here:** Python integers are unbounded and every `line`/`column` field accepts values past 64 bits, so Rust is over-strict above `i64::MAX` across seven field families. The ledger pins the boundary where both agree; the range above it is a Python-first defensive limit on source coordinates (#259's own limits/defensive-parsing requirement), not a case for arbitrary-precision integers through the Rust core. #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,11 @@ 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:** #259 remaining (cp4 → cp5) → #260/#269. One Python-first +side change is queued ahead of nothing and blocks nothing: a documented +defensive limit on source-coordinate integers, which closes the last measured +cp1 divergence (Python accepts coordinates past 64 bits; Rust does not) without +pushing arbitrary-precision integers through the Rust core. ### What #256 asked for that the tree does not have diff --git a/rust/crates/own-ir/src/lib.rs b/rust/crates/own-ir/src/lib.rs index 3a3ad2a0..874d31c9 100644 --- a/rust/crates/own-ir/src/lib.rs +++ b/rust/crates/own-ir/src/lib.rs @@ -3,23 +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 — a claim that is now **measured**, not -//! asserted: `tests/validation_replay.rs` replays a 77-control Python-authored -//! ledger and requires zero Rust-only accepts, zero Rust-only rejects and zero +//! (`ownlang/ownir.py::load`) — a claim that is **measured**, not asserted: +//! `tests/validation_replay.rs` replays a 191-control Python-authored ledger +//! and requires zero Rust-only accepts, zero Rust-only rejects and zero //! error-category mismatches. //! -//! That measurement was worth having. The same sentence used to sit here -//! unbacked, and a differential sweep found **twelve** documents the reference -//! refused and this crate accepted (#259 cp1) — mostly because a field the -//! model never declared cannot be checked by serde, however strict serde is -//! about the fields it *has* been told about. +//! 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. //! -//! * **typed fields are the ones the strict door 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). -//! A field that Python *does* validate must be declared here, or it silently -//! falls into `extra` and escapes checking entirely; +//! * **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 @@ -31,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}; @@ -114,6 +128,16 @@ impl std::fmt::Display for OwnIrError { 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 @@ -511,214 +535,17 @@ pub struct OwnIr { pub extra: Map, } -/// The checks whose **category** must not be decided by serde. -/// -/// Run against the raw document, before typed deserialization. A serde enum -/// would reject `lifetime: "eternal"` perfectly well — and report it as -/// [`OwnIrErrorKind::Shape`], when the contract violated is a closed -/// vocabulary. Likewise a `name: 7` is an identity failure in the reference, -/// not a type failure. Deserializing first and classifying after would make -/// accept/reject parity green while the taxonomy quietly lied. -/// -/// Ordered to follow BR-D1: `components` → `services` → `functions` → -/// `protocols`. -fn gate_semantics(obj: &Map) -> Result<(), OwnIrError> { - gate_components(obj)?; - gate_services(obj)?; - gate_params(obj)?; - gate_protocols(obj) -} - -/// `components[]` — resource shape, then its closed vocabulary, then columns. -fn gate_components(obj: &Map) -> Result<(), OwnIrError> { - for sub in obj - .get("components") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_object) - .filter_map(|c| c.get("subscriptions")) - .filter_map(Value::as_array) - .flatten() - .filter_map(Value::as_object) - { - // 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) — that one guards the tolerant path - // which bypasses this loader entirely. Two doors, not one duplicated - // check: removing either reopens a different hole. - // Shape before vocabulary, in that order: `resource` must BE a string - // before its value can be tested against the closed set. Checking the - // set first (and skipping a non-string) would let `{"resource": 7, - // "column": 0}` be reported as a column problem, which is the wrong - // contract and the wrong category. - match sub.get("resource") { - Some(Value::String(_)) | None => {} - Some(other) => { - return Err(OwnIrError::new( - OwnIrErrorKind::Shape, - format!("subscription 'resource' must be a string, got {other}"), - )) - } - } - if let Some(kind) = sub.get("resource").and_then(Value::as_str) { - if !KNOWN_RESOURCE_KINDS.contains(&kind) { - return Err(OwnIrError::new( - OwnIrErrorKind::Vocabulary, - format!( - "unknown resource kind {kind:?} — a new kind is a \ - vocabulary change that must bump OWNIR_VERSION" - ), - )); - } - } - check_column(sub.get("column"), "subscription")?; - } - Ok(()) -} - -/// `services[]` — name identity, then the DI lifetime vocabulary. -fn gate_services(obj: &Map) -> Result<(), OwnIrError> { - for svc in obj - .get("services") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_object) - { - // The service name is the identity the DI graph joins on: empty and - // non-string are the same defect class, and the reference reports both - // with one message. - match svc.get("name") { - Some(Value::String(n)) if !n.is_empty() => {} - None => {} - _ => { - return Err(OwnIrError::new( - OwnIrErrorKind::Identity, - "service 'name' must be a non-empty string", - )) - } - } - if let Some(v) = svc.get("lifetime") { - let known = v - .as_str() - .is_some_and(|l| ["scoped", "singleton", "transient"].contains(&l)); - if !known { - return Err(OwnIrError::new( - OwnIrErrorKind::Vocabulary, - format!( - "service 'lifetime' must be one of \ - [\"scoped\", \"singleton\", \"transient\"], got {v}" - ), - )); - } - } - } - - Ok(()) -} - -/// `functions[].params[]` — name identity, then the effect vocabulary. -fn gate_params(obj: &Map) -> Result<(), OwnIrError> { - for param in obj - .get("functions") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_object) - .filter_map(|f| f.get("params")) - .filter_map(Value::as_array) - .flatten() - .filter_map(Value::as_object) - { - match param.get("name") { - Some(Value::String(n)) if !n.is_empty() => {} - None => {} - _ => { - return Err(OwnIrError::new( - OwnIrErrorKind::Identity, - "parameter 'name' must be a non-empty string", - )) - } - } - if let Some(v) = param.get("effect") { - let known = v.is_null() - || v.as_str() - .is_some_and(|e| ["borrow", "borrow_mut", "consume", "plain"].contains(&e)); - if !known { - return Err(OwnIrError::new( - OwnIrErrorKind::Vocabulary, - format!( - "parameter 'effect' must be one of \ - [\"borrow\", \"borrow_mut\", \"consume\", \"plain\"], got {v}" - ), - )); - } - } - } - - Ok(()) -} - -/// `protocols[]` — the identity invariant two individually valid records can -/// only violate together. -fn gate_protocols(obj: &Map) -> Result<(), OwnIrError> { - let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); - for proto in obj - .get("protocols") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_object) - { - if let Some(name) = proto.get("name").and_then(Value::as_str) { - if !seen.insert(name) { - return Err(OwnIrError::new( - OwnIrErrorKind::Identity, - format!( - "duplicate protocol name '{name}' — protocol names are \ - the identity findings map back by and must be unique" - ), - )); - } - } - } - Ok(()) -} - -/// The 1-based source-column contract (#317). -/// -/// A column is a positive integer or it is absent. `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. -/// `true` is rejected explicitly — the reference guards the same trap because a -/// Python `bool` is an `int`, and accepting it would fabricate column 1. -fn check_column(value: Option<&Value>, where_: &str) -> Result<(), OwnIrError> { - let Some(v) = value else { return Ok(()) }; - if v.is_null() { - return Ok(()); - } - // `Bool` is called out rather than folded into the wildcard: it is the - // trap the reference guards explicitly (a Python `bool` is an `int`, so - // `True` would otherwise be read as column 1 — a fabricated coordinate). - let ok = match v { - Value::Number(n) => n.as_i64().is_some_and(|i| i >= 1), - _ => false, - }; - if ok { - return Ok(()); - } - Err(OwnIrError::new( - OwnIrErrorKind::Location, - format!("{where_} 'column' must be a 1-based integer or absent, got {v}"), - )) -} - impl OwnIr { /// Parse + shape-check an `OwnIR` JSON document — the **strict door** - /// (BR-D1). Mirrors the acceptance of Python `ownlang.ownir.load`, - /// including the observable order: JSON → root-is-object → version gate → - /// semantic gate → typed shapes. + /// (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 @@ -730,11 +557,6 @@ impl OwnIr { /// [`OwnIrError`] on invalid JSON, a schema-version mismatch, or any field /// that the reference implementation would reject. pub fn from_json(text: &str) -> Result { - // BR-D1 fixes the ORDER, and the spec notes it "is observable through - // which error fires first". A single `from_str::` would collapse - // that: serde would reject a malformed `components` before anything - // could look at `ownir_version`, reporting Shape where the reference - // reports Version. So the gate runs on an untyped value first. 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 { @@ -743,103 +565,41 @@ impl OwnIr { "OwnIR root must be a JSON object", )); }; - Self::gate_version(obj)?; - gate_semantics(obj)?; - let doc: Self = serde_json::from_value(raw).map_err(|e| { + 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!("OwnIR facts are not valid: {e}"), - ) - })?; - doc.validate()?; - Ok(doc) - } - - /// The version gate, run against the untyped document so it precedes every - /// shape check exactly as BR-D1 requires. - /// - /// An absent field means the current version (the only producers that omit - /// it predate versioning). A present `bool` is rejected explicitly: JSON - /// has a real boolean type, but the reference guards the same trap because - /// `True` is an `int` in Python, and accepting `true` as v1 here would let - /// a document through that the reference refuses. - fn gate_version(obj: &Map) -> Result<(), OwnIrError> { - let Some(v) = obj.get("ownir_version") else { - return Ok(()); - }; - // A `Bool` deliberately falls through to `None` with everything else: - // the reference guards it because a Python `bool` is an `int`, and - // accepting `true` here would read as schema v1. - let ver = match v { - Value::Number(n) if n.is_i64() => n.as_i64(), - _ => None, - }; - let Some(ver) = ver else { - return Err(OwnIrError::new( - OwnIrErrorKind::Version, - format!("OwnIR 'ownir_version' must be an integer, got {v}"), - )); - }; - if ver != OWNIR_VERSION { - return Err(OwnIrError::new( - OwnIrErrorKind::Version, 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." + "{VALIDATOR_HOLE}: the strict validator accepted a document serde \ + then refused, so a rule is missing from it — {e}" ), - )); - } - Ok(()) + ) + }) } - /// The checks serde's typing cannot express, for a value built directly - /// rather than loaded. + /// The strict door applied to a value built in memory rather than parsed. /// - /// The full semantic gate (closed vocabularies, the 1-based column rule, - /// protocol identity) runs inside [`OwnIr::from_json`] against the **raw** - /// document, because getting the error *category* right requires checking - /// before serde deserializes — see [`gate_semantics`]. `from_json` is the - /// strict door; this is the residue a typed value can still violate. + /// 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 { + let value = self.to_value()?; + let Some(obj) = value.as_object() else { return Err(OwnIrError::new( - OwnIrErrorKind::Version, - 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." - ), + OwnIrErrorKind::Shape, + "OwnIR root must be a JSON object", )); - } - for s in self.services.iter().flatten() { - if s.name.is_empty() { - return Err(OwnIrError::new( - OwnIrErrorKind::Identity, - "service 'name' must be a non-empty string", - )); - } - } - for p in self - .functions - .iter() - .flatten() - .flat_map(|f| f.params.iter().flatten()) - { - if p.name.is_empty() { - return Err(OwnIrError::new( - OwnIrErrorKind::Identity, - "parameter 'name' must be a non-empty string", - )); - } - } - Ok(()) + }; + strict::validate_document(obj) } /// Serialize back to a JSON value. Together with `from_json` this is the diff --git a/rust/crates/own-ir/src/protocol.rs b/rust/crates/own-ir/src/protocol.rs new file mode 100644 index 00000000..9250463c --- /dev/null +++ b/rust/crates/own-ir/src/protocol.rs @@ -0,0 +1,332 @@ +//! 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 46 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). Both are well-formedness, +//! not shape: every value in such a record has the right type. They are reported +//! as [`OwnIrErrorKind::Shape`] because the six-category taxonomy has no better +//! home, and that imprecision is written down in the ledger rather than +//! smoothed over. + +// `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) +} + +/// 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(shape(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(shape(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 +/// is bounded by `serde_json`'s 128-level parse limit. +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..5365f4a8 --- /dev/null +++ b/rust/crates/own-ir/src/strict.rs @@ -0,0 +1,499 @@ +//! 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 is bounded by `serde_json`'s own 128-level parse limit — a document +/// deep enough to exhaust the stack here never becomes a [`Value`] in the first +/// place. +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(()) +} diff --git a/rust/crates/own-ir/tests/validation_replay.rs b/rust/crates/own-ir/tests/validation_replay.rs index d46226d1..13e7ac94 100644 --- a/rust/crates/own-ir/tests/validation_replay.rs +++ b/rust/crates/own-ir/tests/validation_replay.rs @@ -134,33 +134,36 @@ fn the_two_loaders_accept_the_same_language() { // 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 mut report = String::new(); - if m.rust_only_accept > 0 { - report.push_str(&format!( - "\n\nCRITICAL permissiveness ({}) — the strict door is the gate over \ - untrusted extractor output, and these documents are refused by the \ - reference but analysed by the port:\n {}", + let rows = [ + ( m.rust_only_accept, - permissive.join("\n ") - )); - } - if m.rust_only_reject > 0 { - report.push_str(&format!( - "\n\nover-strictness ({}) — not a hole, but facts the reference \ - analyses today would stop being analysable after cutover:\n {}", + "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_strict.join("\n ") - )); - } - if m.kind_mismatch > 0 { - report.push_str(&format!( - "\n\ncategory mismatch ({}) — both loaders reject, but disagree \ - about WHY. Accept/reject parity can be green while the taxonomy is \ - decorative; this is what stops that:\n {}", + "over-strictness — not a hole, but facts the reference analyses \ + today would stop being analysable after cutover", + &over_strict, + ), + ( m.kind_mismatch, - mismatched.join("\n ") - )); - } + "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}" @@ -172,6 +175,36 @@ fn the_two_loaders_accept_the_same_language() { ); } +#[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 From 1af0dda51f8a047ccf800f2201f14c48c02c83ce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 19:12:58 +0000 Subject: [PATCH 06/11] =?UTF-8?q?docs(P-022):=20cp1=20is=20complete=20?= =?UTF-8?q?=E2=80=94=20record=20it=20on=20all=20three=20status=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proposals index named lowering and MOS but not validation, so the third surface disagreed with the other two the moment cp1 landed. P-022 and #250 were updated in the same change (#250 via the API, no git commit): the status-drift rule says the surfaces move together. Refs #250, #259. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CJF7MBi1ijU5m9cJVWgQsM --- docs/proposals/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/proposals/README.md b/docs/proposals/README.md index 3eb028c8..4f09f340 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: strict-door validation, lowering and MOS parity landed — validation re-censused at 191 controls after a first 0/0/0 proved to be the ledger agreeing with its own author; 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) | From 91246c34b34a4379e82262f29aaf93ded3789836 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 19:22:48 +0000 Subject: [PATCH 07/11] fix(ownir): bound raw-value depth before serializing, not inside the walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on e5f52e8: `OwnIr::validate()` can be handed an arbitrarily deep in-memory value, and nothing stops the recursion. Real, and introduced by this PR — before e5f52e8 `validate()` did not recurse at all. The proposed fix was a depth counter threaded through `events`. Measured against the tree, that would have been dead code. Binary search on the same tree: to_value() survives to ~831, aborts by ~846 validate() survives to ~831, aborts by ~846 Identical, because `validate` serializes first. The stack dies inside `serde_json::to_value` before the validator's own recursion is ever reached, so a counter in `events`/`flow_columns` could never be the check that fires. The bound has to run BEFORE serialization. So the guard is in `to_value`, which already returns `Result`: - `strict::check_depth` measures depth with an EXPLICIT STACK. A recursive depth check would be the failure it is meant to prevent, and would abort rather than return — a stack overflow is not catchable. - `OwnIr::check_raw_depth` walks the raw values the model carries (`extra` maps, both protocol sections, `Subscription::column`) with plain loops; typed nesting is fixed-depth, so no recursion there. - The limit is 128, serde_json's own parse limit. A document that could be PARSED never exceeds it, so this rejects nothing `from_json` accepts. `from_json` was never exposed: serde_json caps nesting at 128 and refuses around 120 event levels. HONESTY NOTE, measured not assumed: this covers depths 129..~800. Above ~804 merely DROPPING the value aborts, because `serde_json::Value` has a recursive `Drop` — nothing this crate does can prevent that, and a test asserting otherwise aborts before it can report. The first version of the regression test used depth 900 and died in `Drop`, which is how the band got measured. The guard stops the serializer, not the type. Also corrected: two doc comments claimed recursion was "bounded by serde_json's 128-level parse limit". True for `from_json`, false for a value built in memory — which is precisely the gap the finding names. Four mutations, all caught — but only after fixing the second test. `the_depth_guard_never_fires_on_a_document_from_json_accepts` originally ran over the ledger alone, and every control is a few levels deep, so tightening the guard to 16 SURVIVED. It now builds the deepest document `from_json` still accepts (50 nested events, ~105 JSON levels) and pins the guard above it. Same lesson as the census: a test that cannot distinguish the mutation is not evidence, whatever it asserts. Refs #250, #259. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CJF7MBi1ijU5m9cJVWgQsM --- rust/crates/own-ir/src/lib.rs | 64 ++++++++++++++- rust/crates/own-ir/src/protocol.rs | 10 ++- rust/crates/own-ir/src/strict.rs | 50 ++++++++++- rust/crates/own-ir/tests/validation_replay.rs | 82 +++++++++++++++++++ 4 files changed, 199 insertions(+), 7 deletions(-) diff --git a/rust/crates/own-ir/src/lib.rs b/rust/crates/own-ir/src/lib.rs index 874d31c9..d337ad63 100644 --- a/rust/crates/own-ir/src/lib.rs +++ b/rust/crates/own-ir/src/lib.rs @@ -605,11 +605,71 @@ impl OwnIr { /// 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` — `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. Measured: the abort begins somewhere + /// between 831 and 846 levels; the limit here is 128, the same bound + /// `serde_json`'s parser applies, so nothing [`OwnIr::from_json`] can + /// accept is refused. + /// /// # 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 { + 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 index 9250463c..72705638 100644 --- a/rust/crates/own-ir/src/protocol.rs +++ b/rust/crates/own-ir/src/protocol.rs @@ -275,8 +275,14 @@ pub(crate) fn validate_method(raw: &Value) -> Result<(), OwnIrError> { /// An ordered event list, recursive over `if` / `while`. /// -/// Absent means empty; a present `null` is not a list and is rejected. Recursion -/// is bounded by `serde_json`'s 128-level parse limit. +/// 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 => &[], diff --git a/rust/crates/own-ir/src/strict.rs b/rust/crates/own-ir/src/strict.rs index 5365f4a8..b3219ada 100644 --- a/rust/crates/own-ir/src/strict.rs +++ b/rust/crates/own-ir/src/strict.rs @@ -226,9 +226,11 @@ fn column(value: Option<&Value>, what: &str) -> Checked { /// reference returns early in both cases. Tightening that would be a /// Rust-only rejection of facts that analyse today. /// -/// Recursion is bounded by `serde_json`'s own 128-level parse limit — a document -/// deep enough to exhaust the stack here never becomes a [`Value`] in the first -/// place. +/// 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(()); @@ -497,3 +499,45 @@ fn protocol_functions(obj: &Map) -> Checked { } Ok(()) } + +/// The nesting depth beyond which a raw [`Value`] is refused. +/// +/// 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. +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/validation_replay.rs b/rust/crates/own-ir/tests/validation_replay.rs index 13e7ac94..ec5f3ff4 100644 --- a/rust/crates/own-ir/tests/validation_replay.rs +++ b/rust/crates/own-ir/tests/validation_replay.rs @@ -284,3 +284,85 @@ fn malformed_input_never_panics() { 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 + // band where the value can still be built and dropped. See the honesty 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"); + + // HONESTY NOTE, measured rather than assumed: this guard covers depths 129 + // to roughly 800. Above ~804 merely DROPPING the value aborts, because + // `serde_json::Value` has a recursive `Drop` — nothing this crate does can + // prevent that, and a test asserting otherwise would abort before it could + // report. The bound stops the serializer, not the type. +} + +#[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 191 of them. Measured — + // that mutation SURVIVED until this document was added. So the test builds + // the deepest document `from_json` still accepts and pins the guard above + // it. + let mut node = serde_json::json!({"ev": "return", "line": 1}); + for _ in 0..50 { + node = serde_json::json!({"ev": "if", "line": 1, "then": [node]}); + } + let deep = serde_json::json!({ + "ownir_version": 0, + "protocol_functions": [{"name": "M", "events": [node]}] + }); + let text = serde_json::to_string(&deep).expect("serializes"); + let doc = OwnIr::from_json(&text).expect( + "a 50-level protocol tree is within serde_json's parse limit and the \ + reference accepts it", + ); + assert!( + doc.to_value().is_ok(), + "the depth guard refused a document the strict door accepted — it is \ + tighter than the parser, which makes it a 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"); + } + } +} From bb94cbd7fd95746ac0ddcc48f85b70ad5684703c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 19:24:21 +0000 Subject: [PATCH 08/11] docs(ownir): the protocol grammar was 47 of the 58, not 46 Recounted from the fixture rather than from memory: of the 58 permissive documents the second census opened, 47 are protocol-grammar cases (`protocols`, `protocol_functions`, and their two ordering families) and 11 are the flow/param column families. A wrong number in a module doc that exists to justify a scope decision is worse than no number. Refs #259. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CJF7MBi1ijU5m9cJVWgQsM --- rust/crates/own-ir/src/protocol.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/own-ir/src/protocol.rs b/rust/crates/own-ir/src/protocol.rs index 72705638..1f33b729 100644 --- a/rust/crates/own-ir/src/protocol.rs +++ b/rust/crates/own-ir/src/protocol.rs @@ -8,7 +8,7 @@ //! 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 46 of 58 permissive cases. +//! is what the second census measured, at 47 of 58 permissive cases. //! //! # What is ported, and what deliberately is not //! From 6839a6b2a3aed2b71e6a65db30446b950f96f67a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 19:58:07 +0000 Subject: [PATCH 09/11] feat(ownir): add WellFormedness, the seventh error category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Veto on filing two protocol rules under `shape`, and the reasoning behind the veto is the part worth keeping. `protocol can never fire` (no barriers with `exit_barriers: false`) and `barrier == opens` were reported as `Shape` while the code comment directly above them said "both are well-formedness, not shape". Every value in such a record has the right type and a legal vocabulary; what is broken is that the record cannot MEAN anything. The justification given was that the taxonomy was already frozen at six. That is backwards. The taxonomy was frozen by the FIRST census; this mechanism was found by the SECOND — the same census that just proved a category set settled early is a claim about the settling, not about the contract. Filing a newly discovered mechanism under the nearest existing name is the exact substitution this enum was built to stop, and it was the substitution being made. So: `OwnIrErrorKind::WellFormedness`, wire name `well_formedness`. Both rules move to it. The enum's doc now states the set is closed by measurement in BOTH directions — it cannot outgrow its evidence (no `Reference` variant, nothing reaches it) and it is not frozen against new evidence. Ledger: 191 -> 193 controls. The two new ones are the acceptance twins the category needs — `exit_barriers: false` WITH a barrier, and a barrier that differs from `opens` — so the rejections are about meaning rather than about the fields being present at all. Four mutations, all caught, including the two that matter most here: each rule reported as `Shape` while still rejecting. Category-only mutations are the only thing that can tell a real taxonomy from a decorative one. Also, per review: 128 is now the ONLY normative depth number. The measured abort points (~831/~846 for serialization, ~804/~851 for `Drop`) are properties of one stack size, build profile and platform. They are good forensics and they were creeping into doc comments as if they were specification; they are out of the normative wording and the PR keeps them as evidence. And the guard's promise is stated precisely instead of absolutely. It was "nothing this crate does can prevent that". Correctly: `to_value()` and `validate()` refuse a too-deep value and RETURN rather than aborting inside the serializer. They do not promise that any `Value` a caller built is safe to hold — `serde_json::Value` has a recursive `Drop`. Guaranteeing that would mean not representing facts as `serde_json::Value`, a representation change and outside cp1. Status, per the same review: cp1 is NOT complete and P-022 + the index now say so. 0/0/0 currently means "over a set from which two known Python-accept/Rust-reject families were removed" — coordinates beyond Rust's integer range, and deep nesting. That is not the parity #259 asks for. Both close Python-first, before this checkpoint may be called complete. #250 is corrected in the same logical change, via the API. Refs #250, #259. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CJF7MBi1ijU5m9cJVWgQsM --- docs/proposals/P-022-rust-core-migration.md | 14 +-- docs/proposals/README.md | 2 +- rust/crates/own-ir/src/lib.rs | 45 +++++++--- rust/crates/own-ir/src/protocol.rs | 24 +++-- rust/crates/own-ir/src/strict.rs | 8 +- rust/crates/own-ir/tests/validation_replay.rs | 23 +++-- tests/fixtures/ownir_validation.json | 90 ++++++++++++++++--- tests/test_ownir_validation_fixtures.py | 44 ++++++--- 8 files changed, 195 insertions(+), 55 deletions(-) diff --git a/docs/proposals/P-022-rust-core-migration.md b/docs/proposals/P-022-rust-core-migration.md index 26cfd5f9..cbaf5660 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 | **complete** | Two censuses, and the second is the point. The first froze 77 controls, closed twelve permissive documents and read 0/0/0 — then independent 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: **191 controls**, which opened a further **58** permissive documents and **9** category mismatches. Closing them was architectural, not incremental — the strict door is now a sequential validator over the raw document (`own-ir/src/strict.rs`) that reproduces BR-D1's interleaving of shape and semantics *per section, in declaration order*. `serde` is the typed constructor, no longer the arbiter, and a document it rejects after validation is reported as a hole in the validator and asserted against. The obligation **acceptance grammar** (`parse_protocol`/`parse_matcher`/`parse_events`/`parse_method`) is ported in `own-ir/src/protocol.rs`; protocol *analysis* is not, and is not part of what the door accepts. Final matrix 29/162 agreed, 0/0/0 across the three failure rows; 27 mutations each caught, five of them only by the validator-hole guard. **One measured divergence stays open and is deliberately not closed here:** Python integers are unbounded and every `line`/`column` field accepts values past 64 bits, so Rust is over-strict above `i64::MAX` across seven field families. The ledger pins the boundary where both agree; the range above it is a Python-first defensive limit on source coordinates (#259's own limits/defensive-parsing requirement), not a case for arbitrary-precision integers through the Rust core. #294 OD-2 remains a separate tolerant-door concern | +| 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,11 +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 (cp4 → cp5) → #260/#269. One Python-first -side change is queued ahead of nothing and blocks nothing: a documented -defensive limit on source-coordinate integers, which closes the last measured -cp1 divergence (Python accepts coordinates past 64 bits; Rust does not) without -pushing arbitrary-precision integers through the Rust core. +**Preferred queue:** Python-first defensive limits → finish cp1 → cp4 → cp5 → +#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 4f09f340..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: strict-door validation, lowering and MOS parity landed — validation re-censused at 191 controls after a first 0/0/0 proved to be the ledger agreeing with its own author; 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-ir/src/lib.rs b/rust/crates/own-ir/src/lib.rs index d337ad63..b70fd8b1 100644 --- a/rust/crates/own-ir/src/lib.rs +++ b/rust/crates/own-ir/src/lib.rs @@ -62,12 +62,18 @@ pub const OWNIR_VERSION: i64 = 0; /// 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 — `tests/fixtures/ownir_validation.json` -/// fails if a declared category has no control exercising it, so the taxonomy -/// cannot outgrow its evidence. There is deliberately no `Reference` variant: -/// 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. +/// 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. @@ -83,6 +89,16 @@ pub enum OwnIrErrorKind { 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 { @@ -96,6 +112,7 @@ impl OwnIrErrorKind { Self::Vocabulary => "vocabulary", Self::Identity => "identity", Self::Location => "location", + Self::WellFormedness => "well_formedness", } } } @@ -606,13 +623,17 @@ impl OwnIr { /// round-trip the oracle's first parity check rides on. /// /// Refuses a document whose raw values nest deeper than - /// `strict::MAX_VALUE_DEPTH` — `serde_json::to_value` recurses over a - /// `Value`, and on a deep enough one it does not fail, it **aborts the + /// `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. Measured: the abort begins somewhere - /// between 831 and 846 levels; the limit here is 128, the same bound - /// `serde_json`'s parser applies, so nothing [`OwnIr::from_json`] can - /// accept is refused. + /// 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 a raw value is nested too deeply, or if serialization diff --git a/rust/crates/own-ir/src/protocol.rs b/rust/crates/own-ir/src/protocol.rs index 1f33b729..7e2710c3 100644 --- a/rust/crates/own-ir/src/protocol.rs +++ b/rust/crates/own-ir/src/protocol.rs @@ -30,11 +30,16 @@ //! //! `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). Both are well-formedness, -//! not shape: every value in such a record has the right type. They are reported -//! as [`OwnIrErrorKind::Shape`] because the six-category taxonomy has no better -//! home, and that imprecision is written down in the ledger rather than -//! smoothed over. +//! 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 @@ -66,6 +71,11 @@ 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 — @@ -118,13 +128,13 @@ pub(crate) fn validate_protocol(raw: &Value) -> Result<&str, OwnIrError> { } }; if barriers.is_empty() && !exit_barriers { - return Err(shape(format!( + 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(shape(format!( + 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)" ))); diff --git a/rust/crates/own-ir/src/strict.rs b/rust/crates/own-ir/src/strict.rs index b3219ada..bf20350e 100644 --- a/rust/crates/own-ir/src/strict.rs +++ b/rust/crates/own-ir/src/strict.rs @@ -500,12 +500,18 @@ fn protocol_functions(obj: &Map) -> Checked { Ok(()) } -/// The nesting depth beyond which a raw [`Value`] is refused. +/// 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. diff --git a/rust/crates/own-ir/tests/validation_replay.rs b/rust/crates/own-ir/tests/validation_replay.rs index ec5f3ff4..6c102ee8 100644 --- a/rust/crates/own-ir/tests/validation_replay.rs +++ b/rust/crates/own-ir/tests/validation_replay.rs @@ -241,7 +241,9 @@ fn every_declared_category_is_exercised_by_a_control() { "category {name:?} is declared but no control exercises it" ); } - // …and the port must be able to name every one of them. + // …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, @@ -249,6 +251,7 @@ fn every_declared_category_is_exercised_by_a_control() { OwnIrErrorKind::Vocabulary, OwnIrErrorKind::Identity, OwnIrErrorKind::Location, + OwnIrErrorKind::WellFormedness, ] { assert!( declared.contains_key(kind.as_str()), @@ -294,7 +297,7 @@ fn a_deep_in_memory_value_is_refused_rather_than_aborting() { // caller built in memory. // // Depth 200 is chosen deliberately: past the 128 guard, and well inside the - // band where the value can still be built and dropped. See the honesty note + // 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 { @@ -318,11 +321,17 @@ fn a_deep_in_memory_value_is_refused_rather_than_aborting() { // through `events`/`flow_columns` would never be the check that fires. assert!(doc.to_value().is_err(), "to_value must refuse it too"); - // HONESTY NOTE, measured rather than assumed: this guard covers depths 129 - // to roughly 800. Above ~804 merely DROPPING the value aborts, because - // `serde_json::Value` has a recursive `Drop` — nothing this crate does can - // prevent that, and a test asserting otherwise would abort before it could - // report. The bound stops the serializer, not the type. + // 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] diff --git a/tests/fixtures/ownir_validation.json b/tests/fixtures/ownir_validation.json index db550178..e5bef566 100644 --- a/tests/fixtures/ownir_validation.json +++ b/tests/fixtures/ownir_validation.json @@ -7,20 +7,22 @@ "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" + "location": "a source coordinate violating the 1-based contract", + "well_formedness": "right types, legal vocabulary, and the record still cannot mean anything" }, "totals": { - "cases": 191, - "accepted": 29, + "cases": 193, + "accepted": 31, "rejected": 162, "by_category": { - "accepted": 29, + "accepted": 31, "identity": 22, "json": 2, "location": 20, - "shape": 93, + "shape": 91, "version": 7, - "vocabulary": 18 + "vocabulary": 18, + "well_formedness": 2 } }, "cases": [ @@ -2829,7 +2831,7 @@ }, { "name": "protocol-never-fires", - "why": "no barriers AND no exit barriers: the rule can structurally never fire, which the reference refuses as decoration. Categorised `shape` for want of a better home — every type here is correct, so this is the taxonomy's known imprecision, not a fit", + "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, @@ -2852,12 +2854,12 @@ }, "raw": false, "verdict": "reject", - "category": "shape", + "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 taxonomy strain", + "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, @@ -2886,9 +2888,77 @@ }, "raw": false, "verdict": "reject", - "category": "shape", + "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", diff --git a/tests/test_ownir_validation_fixtures.py b/tests/test_ownir_validation_fixtures.py index f12dde5a..1441b548 100644 --- a/tests/test_ownir_validation_fixtures.py +++ b/tests/test_ownir_validation_fixtures.py @@ -51,11 +51,17 @@ 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 recorded as `shape` for want of a better home. - Its types are all correct — it is a *well-formedness* rule, and the six- - category taxonomy has no variant for it. That is a known imprecision, written - down rather than smoothed over; a seventh category is not being invented - unilaterally to hide it. + 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 @@ -145,6 +151,8 @@ "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 @@ -785,17 +793,31 @@ def _controls() -> list[dict[str, Any]]: "shape"), _c("protocol-never-fires", "protocols", "no barriers AND no exit barriers: the rule can structurally never " - "fire, which the reference refuses as decoration. Categorised " - "`shape` for want of a better home — every type here is correct, so " - "this is the taxonomy's known imprecision, not a fit", + "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)]}, - "shape"), + "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 taxonomy strain", + "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}])]}, - "shape"), + "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", From 4a0e11c53dcc27d1e97e2f97b34600cb7a8140ac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 22:02:02 +0000 Subject: [PATCH 10/11] docs(ownir): retire five doc claims the rewrite left behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review raised one of these as a nitpick against `af6e782`. Checking it against the current tree found four more, all mine, all introduced by not re-reading doc comments after the architecture changed under them. * the crate doc still said the ledger was **191** controls; the seventh category took it to 193. Same number stale in the depth test. * `Subscription::column` said the check "lives in `OwnIr::validate`". It lived in `gate_components` when that was written, and both are gone: it is `strict::column`, reached from `from_json` and `validate` alike. * `OwnIr::protocols` was the bad one. It still read "Record-level validity beyond that is NOT yet mirrored" — the exact claim this PR retracted. The reference CALLS the obligation parser inside `load()` and wraps its errors, so it is part of the door, and the second census measured that supposed boundary as 47 of its 58 permissive documents. The comment was describing the bug as if it were the design. * `OwnIr::protocol_functions` repeated it by reference. Worth naming: the misleading one was not the one review found. It was found by grepping for every stale symbol at once — `gate_*`, `191`, `OwnIr::validate` — rather than fixing the line that was pointed at. A doc comment that survives the code it documents is the same failure class as a test that survives the behaviour it asserts; neither gets re-read just because something near it changed. Refs #250, #259. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CJF7MBi1ijU5m9cJVWgQsM --- rust/crates/own-ir/src/lib.rs | 28 ++++++++++++------- rust/crates/own-ir/tests/validation_replay.rs | 2 +- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/rust/crates/own-ir/src/lib.rs b/rust/crates/own-ir/src/lib.rs index b70fd8b1..1bc46913 100644 --- a/rust/crates/own-ir/src/lib.rs +++ b/rust/crates/own-ir/src/lib.rs @@ -4,7 +4,7 @@ //! `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`) — a claim that is **measured**, not asserted: -//! `tests/validation_replay.rs` replays a 191-control Python-authored ledger +//! `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. //! @@ -294,7 +294,8 @@ pub struct Subscription { /// 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 [`OwnIr::validate`] where it can answer `Location`. + /// 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)] @@ -528,20 +529,27 @@ pub struct OwnIr { skip_serializing_if = "Option::is_none" )] pub functions: Option>, - /// Obligation-protocol declarations. Held as raw values: the reference - /// checks only that this is a LIST here and delegates each record to the - /// shared obligation parser, which is a separate surface with its own - /// vocabulary. What this crate owns from it is the identity invariant — - /// see [`OwnIr::validate`]. Record-level validity beyond that is NOT yet - /// mirrored; documented in the ledger rather than silently assumed. + /// 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 delegation as `protocols`: list shape - /// here, record parsing elsewhere. + /// 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", diff --git a/rust/crates/own-ir/tests/validation_replay.rs b/rust/crates/own-ir/tests/validation_replay.rs index 6c102ee8..18fe3c93 100644 --- a/rust/crates/own-ir/tests/validation_replay.rs +++ b/rust/crates/own-ir/tests/validation_replay.rs @@ -342,7 +342,7 @@ fn the_depth_guard_never_fires_on_a_document_from_json_accepts() { // 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 191 of them. Measured — + // deep, so a guard set to 16 would pass against all 193 of them. Measured — // that mutation SURVIVED until this document was added. So the test builds // the deepest document `from_json` still accepts and pins the guard above // it. From b447e50e021e00a989e83853b3ebbc69131f64fe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 22:08:17 +0000 Subject: [PATCH 11/11] test(ownir): discover the parser boundary instead of sampling under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review nitpick, and it is the same failure as the last three: the depth test asserted something weaker than it claimed. It built a FIXED 50-wrapper document and said it was "the deepest document `from_json` still accepts". It was not — the parser reaches ~61 wrappers. Any guard between the two passes the test while rejecting documents the door accepts, which is precisely the new-rejection-rule the test exists to forbid. Measured, by restoring the old shape and mutating against both: guard 55 fixed-50: caught discovered: caught guard 105 fixed-50: PASSES discovered: caught So the boundary is now discovered rather than sampled: walk depth upward until `from_json` refuses, keep the deepest document it accepted, and require that one to survive `to_value`. That states the contract exactly — "the guard never fires on anything the parser accepts" — instead of picking a depth that happens to sit under it. Three guard mutations caught (55, 105, and 16 as a regression). Also fixed, from the same review: `#260/#269` opened line 76 of P-022 and markdownlint read it as an ATX heading (MD018). Same class as the one caught in #322; reflowed. DECLINED, with the measurement: the review also asked to reconcile P-022's "31/162, 31 mutations" against the PR body's "29/162, 27". The direction is inverted — P-022 is right and the BODY is stale. Measured: ownir validation ledger OK: 193 controls (31 accept / 162 reject) 31 accepts and 162 rejects is the post-`WellFormedness` ledger, and 31 mutations is 27 plus the four that category needed. The PR body still carried the pre-seventh-category numbers; it is corrected there rather than by editing the correct figures to match the wrong ones. Refs #250, #259. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CJF7MBi1ijU5m9cJVWgQsM --- docs/proposals/P-022-rust-core-migration.md | 4 +- rust/crates/own-ir/tests/validation_replay.rs | 50 ++++++++++++------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/docs/proposals/P-022-rust-core-migration.md b/docs/proposals/P-022-rust-core-migration.md index cbaf5660..86792879 100644 --- a/docs/proposals/P-022-rust-core-migration.md +++ b/docs/proposals/P-022-rust-core-migration.md @@ -72,8 +72,8 @@ 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:** Python-first defensive limits → finish cp1 → cp4 → cp5 → -#260/#269. The limits change is **not** a side quest: it closes the two measured +**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 diff --git a/rust/crates/own-ir/tests/validation_replay.rs b/rust/crates/own-ir/tests/validation_replay.rs index 18fe3c93..47765aa7 100644 --- a/rust/crates/own-ir/tests/validation_replay.rs +++ b/rust/crates/own-ir/tests/validation_replay.rs @@ -343,27 +343,43 @@ fn the_depth_guard_never_fires_on_a_document_from_json_accepts() { // // 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 this document was added. So the test builds - // the deepest document `from_json` still accepts and pins the guard above - // it. - let mut node = serde_json::json!({"ev": "return", "line": 1}); - for _ in 0..50 { - node = serde_json::json!({"ev": "if", "line": 1, "then": [node]}); + // 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 deep = serde_json::json!({ - "ownir_version": 0, - "protocol_functions": [{"name": "M", "events": [node]}] - }); - let text = serde_json::to_string(&deep).expect("serializes"); - let doc = OwnIr::from_json(&text).expect( - "a 50-level protocol tree is within serde_json's parse limit and the \ - reference accepts it", + 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 document the strict door accepted — it is \ - tighter than the parser, which makes it a rejection rule rather than \ - an overflow bound" + "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.