feat(0090): blueprint format forward-compat — Tier-1 tolerance pinned, two-tier discipline codified (closes #156)
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.
This commit is contained in:
@@ -107,9 +107,17 @@ pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> {
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user