From cdd2da6337ebb98740f424d6b423beffeb846472 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 30 Jun 2026 13:28:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(0090):=20blueprint=20format=20forward-comp?= =?UTF-8?q?at=20=E2=80=94=20Tier-1=20tolerance=20pinned,=20two-tier=20disc?= =?UTF-8?q?ipline=20codified=20(closes=20#156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blueprint data format (C24) extends additively under one codified two-tier discipline: - Tier-1 (additive-optional): a new optional field/section is tolerated by an older reader (serde ignores unknown fields — no deny_unknown_fields), with no format_version bump; by C1 a new optional field defaults to prior behaviour, so an old blueprint reproduces the same graph. - Tier-2 (must-understand: a new node type, edge semantics, or structural-axis kind): bumps format_version, so an old reader refuses cleanly (LoadError::UnsupportedVersion / UnknownNodeType) rather than silently building a different graph. Most of #156 already shipped with #155 (cycle 0087): the envelope, omit-defaults canonical form, and both Tier-2 load-path refusals were present and green. This cycle pins the one previously-unproven property — Tier-1 forward-tolerance of an unknown optional field on the current loader — and codifies the discipline. Changes: - New characterization pin (in-crate, blueprint_serde.rs): unknown_optional_field_is_tolerated_byte_identically — an unknown optional key at doc and primitive level loads byte-identically and runs bit-identically. - Two public-seam E2E pins (blueprint_serde_e2e.rs, added by the E2E phase): unknown_envelope_field_tolerated_through_public_api and unknown_primitive_field_tolerated_through_public_api — split the two distinct tolerance surfaces (envelope vs primitive) across the World/cdylib crate boundary, where a deny_unknown_fields added to one would break forward-compat while leaving the in-crate test green. - LoadError::UnsupportedVersion doc reworded from the deferred "is #156" note to the settled two-tier discipline statement. - C24 ledger (docs/design/INDEX.md): Status codification sentence added; #156 dropped from Remaining (#158/#159 stay). Derived fork (recorded on #156): the per-section required-flag mechanism (PNG critical-vs-ancillary) is NOT built — the version envelope already gives the must-understand refusal at version granularity, no current Tier-2 section validates a finer scheme, and C1 holds either way. The pin is a characterization test (GREEN on first run) — it documents existing serde silent-ignore behaviour, no production code changed. Verified: cargo test -p aura-engine green (all targets, three new tests confirmed by name); cargo clippy --workspace --all-targets -- -D warnings clean. --- crates/aura-engine/src/blueprint_serde.rs | 54 ++++++++++++++- .../aura-engine/tests/blueprint_serde_e2e.rs | 68 +++++++++++++++++++ docs/design/INDEX.md | 11 ++- 3 files changed, 129 insertions(+), 4 deletions(-) diff --git a/crates/aura-engine/src/blueprint_serde.rs b/crates/aura-engine/src/blueprint_serde.rs index 2404d28..4f0ab96 100644 --- a/crates/aura-engine/src/blueprint_serde.rs +++ b/crates/aura-engine/src/blueprint_serde.rs @@ -107,9 +107,17 @@ pub fn blueprint_to_json(c: &Composite) -> Result { pub enum LoadError { /// Malformed or structurally-invalid JSON. Json(serde_json::Error), - /// `format_version` the loader does not understand. The full must-understand - /// horizon (multiple versions, unknown-section policy) is #156; #155 supports - /// exactly one version and refuses any other, cleanly. + /// `format_version` the loader does not understand — the **Tier-2 + /// (must-understand)** refusal. The format extends under a two-tier discipline + /// (#156): a **Tier-1** additive-optional change (a new optional field / + /// section) does NOT bump the version — an older reader tolerates it (serde + /// ignores unknown fields; see `unknown_optional_field_is_tolerated_byte_identically`) + /// and, by C1, a new optional field defaults to prior behaviour. A **Tier-2** + /// load-bearing change (a new node type, edge semantics, or structural-axis + /// kind) MUST bump `BLUEPRINT_FORMAT_VERSION`, so an old reader refuses here + /// rather than silently building a different graph (C1 / C18). The + /// per-section required-flag scheme is deferred until a real sub-version Tier-2 + /// addition needs finer granularity than a version bump. UnsupportedVersion { found: u32, supported: u32 }, /// `resolve` returned `None` for a serialized `type_id` (outside the injected /// vocabulary — unknown node, or a construction-arg/sink node not in #155's set). @@ -280,4 +288,44 @@ mod tests { let err = blueprint_from_json(json, &|t| aura_std::std_vocabulary(t)).err().unwrap(); assert!(matches!(err, LoadError::UnsupportedVersion { found: 2, supported: 1 })); } + + #[test] + fn unknown_optional_field_is_tolerated_byte_identically() { + // Forward-compat (C24 / #156, Tier-1): a future writer that has additively + // extended the format emits optional keys the CURRENT loader does not know. + // The loader must silently ignore them and reconstruct the IDENTICAL graph — + // never refuse, never let an unknown key leak into the built blueprint. serde + // ignores unknown fields by default (no deny_unknown_fields). This pins the + // additive property the canonical-golden + omit-defaults discipline claims but + // no existing test exercises. + let canonical = + blueprint_to_json(&crate::test_fixtures::sink_free_sma_cross_signal()).expect("serializes"); + + // Inject unknown optional keys at two additive shapes a new writer might emit: + // a doc-level section ("metadata") and a per-primitive key ("annotations"). + let with_unknown = canonical + .replacen( + r#"{"format_version":1,"#, + r#"{"format_version":1,"metadata":{"author":"future"},"#, + 1, + ) + .replacen(r#"{"type":"Sub"}"#, r#"{"type":"Sub","annotations":["future"]}"#, 1); + assert_ne!(with_unknown, canonical, "probe must actually inject unknown keys"); + + // tolerated, not refused + let loaded = blueprint_from_json(&with_unknown, &|t| aura_std::std_vocabulary(t)) + .expect("an unknown optional field is tolerated, not refused"); + + // the unknown keys did not leak into the graph: re-serialization equals the + // canonical form WITHOUT them (byte-identical reconstruction). + assert_eq!(blueprint_to_json(&loaded).expect("re-serializes"), canonical); + + // and the loaded graph runs bit-identically to the plain canonical one (C1). + let plain = blueprint_from_json(&canonical, &|t| aura_std::std_vocabulary(t)).expect("loads"); + assert_eq!( + run_recording(loaded), + run_recording(plain), + "run diverged under an unknown optional field", + ); + } } diff --git a/crates/aura-engine/tests/blueprint_serde_e2e.rs b/crates/aura-engine/tests/blueprint_serde_e2e.rs index bca252e..1caf235 100644 --- a/crates/aura-engine/tests/blueprint_serde_e2e.rs +++ b/crates/aura-engine/tests/blueprint_serde_e2e.rs @@ -162,3 +162,71 @@ fn unsupported_format_version_fails_named() { "a version the loader does not understand must be refused by name, got {err:?}", ); } + +/// Property (Tier-1 forward-compat at the **document envelope**, public seam — C24 +/// / #156): an unknown optional key beside `format_version`/`blueprint` (the shape a +/// future writer emits for an additive doc-level section that does NOT bump the +/// version) is *tolerated* by the public loader — never refused as a version error, +/// never leaked into the built graph. The `BlueprintDoc` envelope carries no +/// `deny_unknown_fields`, so an older reader silently ignores the section and, by +/// C1, reconstructs the byte-identical blueprint that runs bit-identically. The +/// in-crate twin proves this through crate internals; this pins it across the crate +/// boundary the World/cdylib consumes (where adding `deny_unknown_fields` to the +/// envelope would turn every forward-compatible document into a hard load failure +/// while leaving the in-crate test green). +#[test] +fn unknown_envelope_field_tolerated_through_public_api() { + let canonical = blueprint_to_json(&signal()).expect("serializes"); + + // Inject an unknown optional key into the BlueprintDoc envelope (sibling of + // `format_version`/`blueprint`) — version-independent, no magic version literal. + let with_unknown = + canonical.replacen(r#","blueprint":"#, r#","metadata":{"author":"future"},"blueprint":"#, 1); + assert_ne!(with_unknown, canonical, "probe must actually inject an envelope key"); + + // tolerated, not refused + let loaded = blueprint_from_json(&with_unknown, &|t| std_vocabulary(t)) + .expect("an unknown envelope field is tolerated, not refused"); + + // the unknown key did not leak: re-serialization equals the canonical form. + assert_eq!(blueprint_to_json(&loaded).expect("re-serializes"), canonical, "unknown key leaked"); + + // and it runs bit-identically to the plain canonical twin (C1). + let plain = blueprint_from_json(&canonical, &|t| std_vocabulary(t)).expect("loads"); + assert_eq!( + run_recording(loaded), + run_recording(plain), + "run diverged under an unknown envelope field", + ); +} + +/// Property (Tier-1 forward-compat **inside a primitive node**, public seam — C24 / +/// #156): an unknown optional key inside a serialized primitive object (the shape a +/// future writer emits for an additive *per-node* attribute) is tolerated by the +/// public loader and never leaks into the built graph. `PrimitiveData` carries no +/// `deny_unknown_fields` independently of the envelope, so this is a *distinct* +/// tolerance surface — a `deny_unknown_fields` added to the primitive deserializer +/// alone would refuse a forward-compatible node while the envelope test above stayed +/// green. Loads byte-identically and runs bit-identically to the plain twin. +#[test] +fn unknown_primitive_field_tolerated_through_public_api() { + let canonical = blueprint_to_json(&signal()).expect("serializes"); + + // The param-free `Sub` node serializes as the bare `{"type":"Sub"}`; inject an + // unknown optional key into that primitive object. + let with_unknown = + canonical.replacen(r#"{"type":"Sub"}"#, r#"{"type":"Sub","annotations":["future"]}"#, 1); + assert_ne!(with_unknown, canonical, "probe must actually inject a primitive key"); + + let loaded = blueprint_from_json(&with_unknown, &|t| std_vocabulary(t)) + .expect("an unknown primitive field is tolerated, not refused"); + + assert_eq!(blueprint_to_json(&loaded).expect("re-serializes"), canonical, "unknown key leaked"); + + let plain = blueprint_from_json(&canonical, &|t| std_vocabulary(t)).expect("loads"); + assert_eq!( + run_recording(loaded), + run_recording(plain), + "run diverged under an unknown primitive field", + ); +} diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index f2e7677..e831614 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -1820,8 +1820,17 @@ rejected at construction. The holistic *finalize* faults now also read (`UnconnectedPort` / `RoleKindMismatch` / `UnboundRootRole`) into by-identifier `OpError` variants — still *calling* the unchanged holistic gates (the no-second-validator lockstep preserved), only translating their result. +**Cycle 0090 (#156) codifies the forward-compat two-tier discipline.** Tier-1 +(additive-optional) is serde-default silent-ignore with no `format_version` bump +(a new optional field defaults to prior behaviour, C1) — now proven by +`unknown_optional_field_is_tolerated_byte_identically` (an unknown optional key +loads byte-identically and runs bit-identically). Tier-2 (must-understand: a new +node type, edge semantics, or structural-axis kind) bumps `format_version` so an +old reader refuses cleanly (`LoadError::UnsupportedVersion` / `UnknownNodeType`, +already green). The per-section required-flag scheme is deferred (no current +Tier-2 section to validate it; recorded on #156). **Remaining** -(each its own milestone issue): the forward-compat *must-understand* gate (#156); +(each its own milestone issue): content-addressed topology identity in the manifest (#158, C18); retiring the pre-C24 hard-wired `aura-cli` harnesses (`HarnessKind`, `run_stage1_r`, `*_sweep_family`) once the project-as-crate layer lands (#159, paired with #157's data-authoring surface). **Out of the first cut's round-trippable set**