From 703cf57ca702360841bfc83d1d09b24ef569b666 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 21:48:19 +0000 Subject: [PATCH 1/3] =?UTF-8?q?soa=5Fconfig:=20reject=20concept-id-as-clas?= =?UTF-8?q?sid=20and=20unknown=20keys=20(codex=20P1=20=C3=972=20on=20#908)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P1s from Codex, both real, both now enforced invariants rather than comments — this field had ALREADY been corrected once in #908 (for naming the wrong domain) and still shipped wrong in WIDTH, so a doc-comment warning has demonstrably not been sufficient. P1-1 — concept id written where a u32 classid belongs. CLASSID_ORDER is CanonHigh, so a classid is compose_classid(canon, custom): canon (the minted concept 0xDDCC — domain in the MOST-SIGNIFICANT byte, which is what makes classids sort/prefix-search hierarchically) in the HIGH half, the app render prefix in the LOW half. The example config carried the bare concept "0x0F01", which lands it in the LOW half — classid_canon() then returns 0 and every reader routes the bake as Reserved/default, the exact "total class collapse" that function's own doc warns about. Verified from source, not taken on the reviewer's word. Fix: parse() now parses the classid as a real u32 and rejects a zero canon half with ConceptIdAsClassid { found, suggestion }, where the suggestion is the composed form (0x0F01 -> "0x0F010000") so the operator is told what to write, not merely that they were wrong. New BakeEntry::classid_u32() so callers stop re-parsing the string and re-deciding what a malformed one means. Example config and every test fixture moved to composed 8-hex classids. P1-2 — unknown keys silently ignored. A typo'd `slab_digset` or `hydarte` was dropped by serde and the field defaulted: digest pin gone, hydration silently off, in a config whose entire purpose is failing loudly at boot. Fix: #[serde(deny_unknown_fields)] on BakeEntry and SoaConfig. Both falsifiers verified by the disable-the-fix run: with the canon check stubbed to `if false` and both deny_unknown_fields removed, exactly the two new tests go red (16 passed / 2 failed) and no others — so neither is passing for an unrelated reason. Each is two-sided: the bad shape is rejected AND the good shape is accepted with its value intact, so the rules cannot pass by rejecting everything. Note the cross-check earned its keep: the_shipped_example_config_parses_ through_this_parser stayed green only because the example was corrected alongside the parser — fixing one without the other would have gone red. 18/18 soa_config, clippy clean, fmt clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NMeiLmtDKhomJNSo2ecbJw --- .../examples/soa-config.example.yaml | 22 ++- crates/lance-graph/src/soa_config.rs | 186 +++++++++++++++--- 2 files changed, 180 insertions(+), 28 deletions(-) diff --git a/crates/lance-graph/examples/soa-config.example.yaml b/crates/lance-graph/examples/soa-config.example.yaml index 96c6cbda..27780930 100644 --- a/crates/lance-graph/examples/soa-config.example.yaml +++ b/crates/lance-graph/examples/soa-config.example.yaml @@ -82,7 +82,7 @@ bakes: # two entries pointing at one table means one # of them is either reading garbage or racing # the other's writes. - classid: "0x0F01" # Hex classid (0x-prefixed) identifying this + classid: "0x0F010000" # Hex classid (0x-prefixed) identifying this # bake's SoA node layout — see the "CANON — # Minimal SoA node" section of the repo's # CLAUDE.md. Get this wrong and a reader casts @@ -95,8 +95,20 @@ bakes: # looking value you made up will collide with a # real concept in a different domain. The three # entries below use real geo concepts: - # 0x0F01 osm_node · 0x0F02 osm_way - # 0x0F03 osm_relation + # 0x0F010000 = osm_node (canon 0x0F01) + # 0x0F020000 = osm_way (canon 0x0F02) + # 0x0F030000 = osm_relation (canon 0x0F03) + # + # NOTE THE WIDTH: a classid is a FULL u32 — + # canon (the minted concept) in the HIGH half, + # custom (app render prefix, 0x0000 when none) + # in the LOW half. Writing the bare concept + # "0x0F01" lands it in the LOW half, canon + # reads as 0, and every reader routes the bake + # as Reserved/default. The parser now rejects + # that shape and suggests the composed form — + # this example shipped it wrong once (codex P1 + # on #908), which is why the check exists. # (An earlier draft of this file used 0x0D01 # for an "ontology" bake. 0x0D01 is real — it # is `hr_employee` in the HR domain. That is @@ -120,7 +132,7 @@ bakes: # --- geo.munich: NOT hydrated — read remotely, on demand. ---------------- - name: geo-munich table: geo.munich.lance - classid: "0x0F02" + classid: "0x0F020000" # slab_digest omitted here on purpose — this bake floats to whatever the # table currently holds rather than pinning a specific bake. hydrate: false # Served straight from the object store on @@ -133,7 +145,7 @@ bakes: # --- geo.relations: NOT hydrated — a reference bake read cold. ----------- - name: geo-relations table: geo.relations.lance - classid: "0x0F03" + classid: "0x0F030000" slab_digest: "sha256:REPLACE_WITH_REAL_DIGEST" hydrate: false # A reference bake consulted occasionally, not # on the hot request path — remote reads are diff --git a/crates/lance-graph/src/soa_config.rs b/crates/lance-graph/src/soa_config.rs index a7bb3add..e494f8af 100644 --- a/crates/lance-graph/src/soa_config.rs +++ b/crates/lance-graph/src/soa_config.rs @@ -91,13 +91,27 @@ pub enum OnExisting { /// lives in, the classid that identifies its node layout, and whether this /// deployment should pull it to local disk at boot. #[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)] +// A typo'd key (`slab_digset`, `hydarte`) would otherwise be SILENTLY ignored and +// its field defaulted — dropping a digest pin or flipping hydration off in a +// config whose whole purpose is to fail loudly at boot. Reject instead. +#[serde(deny_unknown_fields)] pub struct BakeEntry { /// Human-facing key, unique within one config object. Not a filesystem /// path — just a lookup name (see [`SoaConfig::find`]). pub name: String, /// The Lance table name under `ledger_prefix`. pub table: String, - /// Hex classid, e.g. `"0x0F01"`, identifying this bake's node layout. + /// Hex classid, e.g. `"0x0F010000"`, identifying this bake's node layout. + /// + /// **A FULL u32 classid, not a u16 concept id.** A classid is + /// `compose_classid(canon, custom)` — canon (the minted concept, e.g. + /// `0x0F01` = `osm_node`) in the HIGH half, custom (the app render + /// prefix, `0x0000` when none) in the LOW half. Writing the bare concept + /// `"0x0F01"` here puts it in the LOW half, so `classid_canon` returns + /// **0** and every reader routes the bake as Reserved/default — the + /// "total class collapse" `ogar_codebook::classid_canon` warns about. + /// [`parse`] rejects that shape rather than letting it reach a reader; + /// use [`BakeEntry::classid_u32`] to read the value. pub classid: String, /// Digest of the bake's slab, when the deployment pins one. Absent /// means "trust whatever is at `table` right now". @@ -111,6 +125,7 @@ pub struct BakeEntry { /// The parsed, validated contents of one deployment's `config.yaml`. #[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] pub struct SoaConfig { /// Schema major version. [`parse`] refuses anything other than /// [`CONFIG_SCHEMA_VERSION`]. @@ -143,8 +158,11 @@ pub enum ConfigError { DuplicateTable(String), /// A required string field was empty. Carries the field's name. EmptyField(&'static str), - /// `classid` did not parse as a `0x`-prefixed hex value. + /// `classid` did not parse as a `0x`-prefixed hex value that fits u32. BadClassid(String), + /// `classid` parsed, but its CANON half is zero — the signature of a + /// bare u16 concept id written where a composed u32 classid belongs. + ConceptIdAsClassid { found: String, suggestion: String }, } impl fmt::Display for ConfigError { @@ -165,8 +183,17 @@ impl fmt::Display for ConfigError { write!(f, "config.yaml field must not be empty: {field}") } ConfigError::BadClassid(classid) => { - write!(f, "classid {classid:?} is not a 0x-prefixed hex value") + write!( + f, + "classid {classid:?} is not a 0x-prefixed hex value that fits u32" + ) } + ConfigError::ConceptIdAsClassid { found, suggestion } => write!( + f, + "classid {found:?} has a ZERO canon half — this is a bare u16 concept id, \ + not a composed u32 classid. Every reader would route this bake as \ + Reserved/default. Did you mean {suggestion:?}?" + ), } } } @@ -180,6 +207,16 @@ pub fn config_key(repo: &str) -> String { format!("{CONFIG_ROOT}/{repo}/{CONFIG_BASENAME}") } +/// A `0x`-prefixed hex string as a `u32`, or `None` if it is not one (missing +/// prefix, empty, non-hex digit, or wider than u32). +fn parse_classid_hex(s: &str) -> Option { + let digits = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))?; + if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + u32::from_str_radix(digits, 16).ok() +} + /// Parse and validate a `config.yaml` body into a [`SoaConfig`]. /// /// Validation rejects, each with its own [`ConfigError`] variant: @@ -190,7 +227,11 @@ pub fn config_key(repo: &str) -> String { /// disk is always a mistake — one of them would be reading garbage or /// racing the other's writes), /// - an empty `ledger_prefix`, `name`, or `table`, -/// - a `classid` that is not `0x`-prefixed hex. +/// - a `classid` that is not `0x`-prefixed hex fitting u32, +/// - a `classid` whose CANON half is zero (a bare u16 concept id written +/// where a composed u32 classid belongs), +/// - any unknown key, via `deny_unknown_fields` on both structs — a typo +/// must not silently default a field. pub fn parse(yaml: &str) -> Result { let config: SoaConfig = serde_yaml::from_str(yaml).map_err(|e| ConfigError::Yaml(e.to_string()))?; @@ -223,14 +264,23 @@ pub fn parse(yaml: &str) -> Result { return Err(ConfigError::DuplicateTable(bake.table.clone())); } - let hex = bake - .classid - .strip_prefix("0x") - .or_else(|| bake.classid.strip_prefix("0X")); - match hex { - Some(digits) if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_hexdigit()) => { - } - _ => return Err(ConfigError::BadClassid(bake.classid.clone())), + let value = parse_classid_hex(&bake.classid) + .ok_or_else(|| ConfigError::BadClassid(bake.classid.clone()))?; + + // The canon half is THE class discriminator. A zero canon means the + // operator wrote a bare u16 concept id (`0x0F01`) where a composed + // u32 classid (`0x0F010000`) belongs — readable-looking, silently + // routed as Reserved/default. Caught here with the composed form the + // author almost certainly meant, rather than at a reader that has + // already mis-cast the bytes. + if lance_graph_contract::ogar_codebook::classid_canon(value) == 0 && value != 0 { + return Err(ConfigError::ConceptIdAsClassid { + found: bake.classid.clone(), + suggestion: format!( + "0x{:08X}", + lance_graph_contract::ogar_codebook::compose_classid(value as u16, 0) + ), + }); } } @@ -255,6 +305,16 @@ impl SoaConfig { } } +impl BakeEntry { + /// The declared classid as the `u32` it is. Infallible on any entry that + /// came through [`parse`] — validation already rejected anything that + /// does not parse — so this is the accessor callers should use instead of + /// re-parsing the string and re-deciding what a malformed one means. + pub fn classid_u32(&self) -> Option { + parse_classid_hex(&self.classid) + } +} + /// Compute the versioned table name an [`OnExisting::NewVersion`] refresh /// writes to. `berlin.lance` with nanos `N` becomes `berlin..lance`; a /// table name with no `.lance` suffix gets `.` appended. @@ -282,12 +342,12 @@ on_existing: new_version bakes: - name: berlin table: berlin.lance - classid: "0x0F01" + classid: "0x0F010000" slab_digest: "sha256:abc123" hydrate: true - name: munich table: munich.lance - classid: "0x0F02" + classid: "0x0F020000" hydrate: false "# } @@ -302,7 +362,7 @@ bakes: let berlin = config.find("berlin").expect("berlin must be found"); assert_eq!(berlin.table, "berlin.lance"); - assert_eq!(berlin.classid, "0x0F01"); + assert_eq!(berlin.classid, "0x0F010000"); assert_eq!(berlin.slab_digest.as_deref(), Some("sha256:abc123")); assert!(berlin.hydrate); @@ -321,7 +381,7 @@ ledger_prefix: "lance-graph/ledger" bakes: - name: berlin table: berlin.lance - classid: "0x0F01" + classid: "0x0F010000" "#; let config = parse(yaml).expect("must parse"); assert_eq!(config.on_existing, OnExisting::Refuse); @@ -358,10 +418,10 @@ ledger_prefix: "lance-graph/ledger" bakes: - name: berlin table: berlin.lance - classid: "0x0F01" + classid: "0x0F010000" - name: berlin table: berlin2.lance - classid: "0x0F02" + classid: "0x0F020000" "#; let err = parse(yaml).expect_err("duplicate name must be rejected"); assert_eq!(err, ConfigError::DuplicateName("berlin".to_string())); @@ -382,10 +442,10 @@ ledger_prefix: "lance-graph/ledger" bakes: - name: berlin table: shared.lance - classid: "0x0F01" + classid: "0x0F010000" - name: munich table: shared.lance - classid: "0x0F02" + classid: "0x0F020000" "#; let err = parse(yaml).expect_err("duplicate table must be rejected"); assert_eq!(err, ConfigError::DuplicateTable("shared.lance".to_string())); @@ -420,7 +480,7 @@ ledger_prefix: "lance-graph/ledger" bakes: - name: "" table: berlin.lance - classid: "0x0F01" + classid: "0x0F010000" "#; let err = parse(yaml).expect_err("empty name must be rejected"); assert_eq!(err, ConfigError::EmptyField("name")); @@ -437,7 +497,7 @@ ledger_prefix: "lance-graph/ledger" bakes: - name: berlin table: "" - classid: "0x0F01" + classid: "0x0F010000" "#; let err = parse(yaml).expect_err("empty table must be rejected"); assert_eq!(err, ConfigError::EmptyField("table")); @@ -459,7 +519,7 @@ bakes: let err = parse(yaml).expect_err("classid without 0x prefix must be rejected"); assert_eq!(err, ConfigError::BadClassid("F01".to_string())); - let fixed = yaml.replace(r#"classid: "F01""#, r#"classid: "0xF01""#); + let fixed = yaml.replace(r#"classid: "F01""#, r#"classid: "0xF010000""#); assert!(parse(&fixed).is_ok()); } @@ -477,6 +537,86 @@ bakes: assert_eq!(err, ConfigError::BadClassid("0xZZ".to_string())); } + /// **The concept-id-as-classid trap** (codex P1 on #908 — this repo's own + /// example config shipped it wrong). + /// + /// `0x0F01` is a real minted concept, and as a *string* it looks entirely + /// plausible in a `classid:` field. Read as the u32 it claims to be, the + /// concept lands in the LOW half, `classid_canon` returns 0, and every + /// reader routes the bake as Reserved/default — silently. Two-sided: the + /// bare concept must be REJECTED and the composed form must be ACCEPTED, + /// so this cannot pass by rejecting everything. + #[test] + fn rejects_a_bare_concept_id_written_where_a_composed_classid_belongs() { + let yaml = |cid: &str| { + format!( + "version: 1\nledger_prefix: \"lance-graph/ledger\"\nbakes:\n \ + - name: berlin\n table: berlin.lance\n classid: \"{cid}\"\n" + ) + }; + + let err = parse(&yaml("0x0F01")).expect_err("a bare u16 concept id must be rejected"); + match err { + ConfigError::ConceptIdAsClassid { found, suggestion } => { + assert_eq!(found, "0x0F01"); + // The suggestion must be the composed form, so the operator is + // told what to write rather than merely that they were wrong. + assert_eq!(suggestion, "0x0F010000"); + } + other => panic!("expected ConceptIdAsClassid, got {other:?}"), + } + + // The paired half: the composed form parses. Without this the test + // would pass even if the rule rejected every classid. + let ok = parse(&yaml("0x0F010000")).expect("the composed classid must be accepted"); + assert_eq!( + ok.bakes[0].classid_u32(), + Some(0x0F01_0000), + "classid_u32 must return the parsed value" + ); + assert_eq!( + lance_graph_contract::ogar_codebook::classid_canon(0x0F01_0000), + 0x0F01, + "and its canon half must be the concept — the whole point of the rule" + ); + } + + /// **A typo must not silently default a field.** `slab_digset` would + /// otherwise be ignored and the digest pin dropped; `hydarte` would be + /// ignored and hydration silently turned off. Two-sided: the typo is + /// rejected, the correct spelling is accepted with its value intact. + #[test] + fn rejects_unknown_keys_instead_of_silently_defaulting_them() { + let with = |key: &str, val: &str| { + format!( + "version: 1\nledger_prefix: \"lance-graph/ledger\"\nbakes:\n \ + - name: berlin\n table: berlin.lance\n \ + classid: \"0x0F010000\"\n {key}: {val}\n" + ) + }; + + for (typo, val) in [("slab_digset", "\"sha256:abc\""), ("hydarte", "true")] { + assert!( + parse(&with(typo, val)).is_err(), + "typo {typo:?} must be REJECTED, not silently ignored and defaulted" + ); + } + + // Correct spellings still work, and carry their values — proving the + // rejection above is about the KEY being unknown, not about the + // parser having become uniformly hostile. + let ok = parse(&with("slab_digest", "\"sha256:abc\"")).expect("correct key must parse"); + assert_eq!(ok.bakes[0].slab_digest.as_deref(), Some("sha256:abc")); + let ok = parse(&with("hydrate", "true")).expect("correct key must parse"); + assert!(ok.bakes[0].hydrate); + + // An unknown key at the TOP level is rejected too, not just in a bake. + assert!( + parse("version: 1\nledger_prefix: \"p\"\nbakes: []\nledgerprefix: \"typo\"\n").is_err(), + "an unknown top-level key must be rejected" + ); + } + #[test] fn config_key_produces_the_exact_expected_string() { assert_eq!(config_key("lance-graph"), ".config/lance-graph/config.yaml"); From 9d8031276e8594c7b04ffae60425c185d77c835d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 21:54:29 +0000 Subject: [PATCH 2/3] =?UTF-8?q?soa=5Fconfig:=20back=20out=20the=20classid?= =?UTF-8?q?=20half-policing=20=E2=80=94=20it=20was=20enforcement=20on=20an?= =?UTF-8?q?=20inference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator correction: "It's cleaner to read the left Bytes and don't enforce something that you didn't understand." Correct, and the check I added was wrong on two counts I could have verified before writing it: - A ZERO CANON IS LEGAL, not a typo signature. The zero-fallback ladder (CLAUDE.md, locked 2026-06-13) states a zero tier means "not consulted" — dormant, reserved, explicitly never an error. My rule conflated an operator typo with a legitimately dormant/bootstrap class and would have refused valid configs. - PRE-FLIP STORED FORMS EXIST. `classid_canon_compat` ships precisely to serve both stored orders; a legacy-order classid it reads correctly would have been rejected outright by my strict check. I generalised a policy from one example being wrong. One wrong example is evidence about that example. What the parser validates now: ONLY that the classid is 0x-prefixed hex fitting the u32 the canonical node key reserves at bytes 0..4. That is structural — the width is the key layout, not an inference about intent. It no longer polices either half. Docs corrected in the same pass, because they were wrong in the same direction: the LOW half is not padding and not "0x0000 when none". It carries the app/render half — ClassView + WideFieldMask ergonomics and slot-schema switching — and is a slot a CONSUMER FILLS (operator's example: a session writing an ontology routing value into it). A config carrying ...0000 declares that slot UNSET, not meaningless. The routing guidance is now what it should have been from the start: read the LEFT bytes — high half is the minted concept 0xDDCC with the domain in the top byte, which is what makes classids sort and prefix-search hierarchically. KEPT from the prior commit, because neither needs a semantic guess: - #[serde(deny_unknown_fields)] on both structs (codex P1-2) — a typo'd `slab_digset`/`hydarte` silently defaulting a field is unambiguously wrong in a config whose purpose is failing loudly at boot. Its two-sided test stays. - BakeEntry::classid_u32() — an accessor, no policy. - Composed classids in the example/fixtures — correct as documentation of the common case, now without the false claim that other shapes are rejected. 17/17 soa_config (the removed rule's test went with it), clippy clean, fmt clean. The shipped-example-parses cross-check still green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NMeiLmtDKhomJNSo2ecbJw --- .../examples/soa-config.example.yaml | 31 +++-- crates/lance-graph/src/soa_config.rs | 108 +++++------------- 2 files changed, 49 insertions(+), 90 deletions(-) diff --git a/crates/lance-graph/examples/soa-config.example.yaml b/crates/lance-graph/examples/soa-config.example.yaml index 27780930..981bbbe4 100644 --- a/crates/lance-graph/examples/soa-config.example.yaml +++ b/crates/lance-graph/examples/soa-config.example.yaml @@ -100,15 +100,28 @@ bakes: # 0x0F030000 = osm_relation (canon 0x0F03) # # NOTE THE WIDTH: a classid is a FULL u32 — - # canon (the minted concept) in the HIGH half, - # custom (app render prefix, 0x0000 when none) - # in the LOW half. Writing the bare concept - # "0x0F01" lands it in the LOW half, canon - # reads as 0, and every reader routes the bake - # as Reserved/default. The parser now rejects - # that shape and suggests the composed form — - # this example shipped it wrong once (codex P1 - # on #908), which is why the check exists. + # the same width the canonical node key + # reserves at bytes 0..4. Read the LEFT bytes + # to route: the high half is the minted + # concept 0xDDCC, domain in the top byte, so + # classids sort/prefix-search by hierarchy. + # + # The LOW half is NOT padding — it carries the + # app/render half: ClassView + WideFieldMask + # ergonomics and slot-schema switching. + # "0x0000" means "no app skin" — one legal + # value among many, and a slot a consumer + # FILLS (e.g. a session writing an ontology + # routing value there). A config carrying + # ...0000 declares the slot UNSET, not + # meaningless. + # + # The parser checks only that this is + # 0x-prefixed hex fitting u32. It deliberately + # does NOT police the halves — a zero canon is + # a legal dormant state (zero-fallback ladder) + # and pre-flip forms are read via + # classid_canon_compat. # (An earlier draft of this file used 0x0D01 # for an "ontology" bake. 0x0D01 is real — it # is `hr_employee` in the HR domain. That is diff --git a/crates/lance-graph/src/soa_config.rs b/crates/lance-graph/src/soa_config.rs index e494f8af..55bfa887 100644 --- a/crates/lance-graph/src/soa_config.rs +++ b/crates/lance-graph/src/soa_config.rs @@ -103,15 +103,25 @@ pub struct BakeEntry { pub table: String, /// Hex classid, e.g. `"0x0F010000"`, identifying this bake's node layout. /// - /// **A FULL u32 classid, not a u16 concept id.** A classid is - /// `compose_classid(canon, custom)` — canon (the minted concept, e.g. - /// `0x0F01` = `osm_node`) in the HIGH half, custom (the app render - /// prefix, `0x0000` when none) in the LOW half. Writing the bare concept - /// `"0x0F01"` here puts it in the LOW half, so `classid_canon` returns - /// **0** and every reader routes the bake as Reserved/default — the - /// "total class collapse" `ogar_codebook::classid_canon` warns about. - /// [`parse`] rejects that shape rather than letting it reach a reader; - /// use [`BakeEntry::classid_u32`] to read the value. + /// **A FULL u32 classid** — the same width the canonical node key + /// reserves at bytes `0..4`. Under the active `CanonHigh` order the + /// LEFT (high) bytes carry the minted concept `0xDDCC` — domain in the + /// most-significant byte, which is what makes classids sort and + /// prefix-search hierarchically. **Read the left bytes to route.** + /// + /// The LOW half is not padding: it carries the app/render half — + /// `ClassView` + `WideFieldMask` ergonomics and slot-schema switching. + /// `0x0000` there means "no app skin", one legal value among many, and + /// it is a slot a consumer FILLS — e.g. a session writing an ontology + /// routing value into it. So a config carrying `…0000` is declaring the + /// slot unset, not declaring it meaningless. + /// + /// [`parse`] validates only that this is `0x`-prefixed hex fitting u32. + /// It deliberately does **not** police the halves: a zero canon is a + /// legal dormant state under the zero-fallback ladder, and pre-flip + /// stored forms are legitimately read via `classid_canon_compat`. Use + /// [`BakeEntry::classid_u32`] to read the value and the codebook's own + /// accessors to split it. pub classid: String, /// Digest of the bake's slab, when the deployment pins one. Absent /// means "trust whatever is at `table` right now". @@ -160,9 +170,6 @@ pub enum ConfigError { EmptyField(&'static str), /// `classid` did not parse as a `0x`-prefixed hex value that fits u32. BadClassid(String), - /// `classid` parsed, but its CANON half is zero — the signature of a - /// bare u16 concept id written where a composed u32 classid belongs. - ConceptIdAsClassid { found: String, suggestion: String }, } impl fmt::Display for ConfigError { @@ -188,12 +195,6 @@ impl fmt::Display for ConfigError { "classid {classid:?} is not a 0x-prefixed hex value that fits u32" ) } - ConfigError::ConceptIdAsClassid { found, suggestion } => write!( - f, - "classid {found:?} has a ZERO canon half — this is a bare u16 concept id, \ - not a composed u32 classid. Every reader would route this bake as \ - Reserved/default. Did you mean {suggestion:?}?" - ), } } } @@ -228,8 +229,6 @@ fn parse_classid_hex(s: &str) -> Option { /// racing the other's writes), /// - an empty `ledger_prefix`, `name`, or `table`, /// - a `classid` that is not `0x`-prefixed hex fitting u32, -/// - a `classid` whose CANON half is zero (a bare u16 concept id written -/// where a composed u32 classid belongs), /// - any unknown key, via `deny_unknown_fields` on both structs — a typo /// must not silently default a field. pub fn parse(yaml: &str) -> Result { @@ -264,24 +263,15 @@ pub fn parse(yaml: &str) -> Result { return Err(ConfigError::DuplicateTable(bake.table.clone())); } - let value = parse_classid_hex(&bake.classid) + // Structural only: it must be 0x-prefixed hex that fits the u32 the + // canonical node key reserves for it. NO semantic check on the halves + // — a zero canon is a legal dormant/bootstrap state under the + // zero-fallback ladder (CLAUDE.md: a zero tier is "not consulted", + // never an error), and pre-flip stored forms are legitimately read by + // `classid_canon_compat`. Rejecting either here would refuse valid + // configs on an inference about intent this parser has no basis for. + parse_classid_hex(&bake.classid) .ok_or_else(|| ConfigError::BadClassid(bake.classid.clone()))?; - - // The canon half is THE class discriminator. A zero canon means the - // operator wrote a bare u16 concept id (`0x0F01`) where a composed - // u32 classid (`0x0F010000`) belongs — readable-looking, silently - // routed as Reserved/default. Caught here with the composed form the - // author almost certainly meant, rather than at a reader that has - // already mis-cast the bytes. - if lance_graph_contract::ogar_codebook::classid_canon(value) == 0 && value != 0 { - return Err(ConfigError::ConceptIdAsClassid { - found: bake.classid.clone(), - suggestion: format!( - "0x{:08X}", - lance_graph_contract::ogar_codebook::compose_classid(value as u16, 0) - ), - }); - } } Ok(config) @@ -537,50 +527,6 @@ bakes: assert_eq!(err, ConfigError::BadClassid("0xZZ".to_string())); } - /// **The concept-id-as-classid trap** (codex P1 on #908 — this repo's own - /// example config shipped it wrong). - /// - /// `0x0F01` is a real minted concept, and as a *string* it looks entirely - /// plausible in a `classid:` field. Read as the u32 it claims to be, the - /// concept lands in the LOW half, `classid_canon` returns 0, and every - /// reader routes the bake as Reserved/default — silently. Two-sided: the - /// bare concept must be REJECTED and the composed form must be ACCEPTED, - /// so this cannot pass by rejecting everything. - #[test] - fn rejects_a_bare_concept_id_written_where_a_composed_classid_belongs() { - let yaml = |cid: &str| { - format!( - "version: 1\nledger_prefix: \"lance-graph/ledger\"\nbakes:\n \ - - name: berlin\n table: berlin.lance\n classid: \"{cid}\"\n" - ) - }; - - let err = parse(&yaml("0x0F01")).expect_err("a bare u16 concept id must be rejected"); - match err { - ConfigError::ConceptIdAsClassid { found, suggestion } => { - assert_eq!(found, "0x0F01"); - // The suggestion must be the composed form, so the operator is - // told what to write rather than merely that they were wrong. - assert_eq!(suggestion, "0x0F010000"); - } - other => panic!("expected ConceptIdAsClassid, got {other:?}"), - } - - // The paired half: the composed form parses. Without this the test - // would pass even if the rule rejected every classid. - let ok = parse(&yaml("0x0F010000")).expect("the composed classid must be accepted"); - assert_eq!( - ok.bakes[0].classid_u32(), - Some(0x0F01_0000), - "classid_u32 must return the parsed value" - ); - assert_eq!( - lance_graph_contract::ogar_codebook::classid_canon(0x0F01_0000), - 0x0F01, - "and its canon half must be the concept — the whole point of the rule" - ); - } - /// **A typo must not silently default a field.** `slab_digset` would /// otherwise be ignored and the digest pin dropped; `hydarte` would be /// ignored and hydration silently turned off. Two-sided: the typo is From bc818fcab3e2957eeec2f2a0aedc0c2abfa0c192 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 21:57:47 +0000 Subject: [PATCH 3/3] soa_config: document the classid low half as screen-region addressing, with the C2 caveat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator: the low half also serves ClassView/WideFieldMask — "meaning you address a screen region akin to CSS." Verified before writing it, rather than taking the analogy on trust: - `class_view.rs:70` FieldMask(u64) — bit N = the Nth field in the class's ordered field list, stable and append-only. - `class_view.rs:221` WideFieldMask(WideRepr) — the same logical positions for classes past 64 fields, promoted losslessly from FieldMask. - a2ui-rs is the shipped consumer of exactly this: `NodeDelta { key, mask_words, values }` — "don't push pixels, address the screen." So the analogy holds for ADDRESSING: the low half selects the ClassView (per-app template/skin) the way a CSS selector picks an element, and the field mask selects which of that view's fields are in play the way declarations pick properties. AND THE ONE PLACE IT MUST NOT BE FOLLOWED, which is why this is a doc change worth making rather than a nice line in a chat: a field mask is **presence, never semantics** (`class_view.rs` C2, stated there in those words). `has(n)` answers "is field n populated here"; it must never gate "field n means something different here." CSS's cascade genuinely does change which rule wins — so an unqualified CSS analogy invites precisely the violation C2 forbids. Both halves are now recorded together, because the useful analogy and its failure mode travel as a pair or not at all. Intra-doc links to FieldMask/WideFieldMask verified to resolve (the remaining `cargo doc` link warnings in this crate are pre-existing and unrelated: r:KNOWS, SpoRecord, relation, get_associated, find_path). 17/17 soa_config, fmt clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NMeiLmtDKhomJNSo2ecbJw --- crates/lance-graph/src/soa_config.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/lance-graph/src/soa_config.rs b/crates/lance-graph/src/soa_config.rs index 55bfa887..0c0d9be8 100644 --- a/crates/lance-graph/src/soa_config.rs +++ b/crates/lance-graph/src/soa_config.rs @@ -116,6 +116,27 @@ pub struct BakeEntry { /// routing value into it. So a config carrying `…0000` is declaring the /// slot unset, not declaring it meaningless. /// + /// # The addressing model, and the one place the CSS analogy breaks + /// + /// Low half + field mask compose into **screen-region addressing**, in + /// the CSS sense: the low half selects the `ClassView` (the per-app + /// template/skin) the way a selector picks an element, and the + /// [`FieldMask`]/[`WideFieldMask`] selects which of that view's fields + /// are in play the way declarations pick properties. That is the whole + /// basis of a2ui-rs's "don't push pixels — address the screen": a + /// `NodeDelta` carries a 16-byte key plus mask words, never a rendered + /// region. + /// + /// **Where the analogy must not be followed:** a field mask is + /// *presence, never semantics* (`class_view.rs` C2). `has(n)` answers + /// "is field n populated here" — it must NEVER gate "field n means + /// something different here." CSS's cascade does change which rule + /// wins; a mask never changes what a field means. Read the analogy for + /// addressing only. + /// + /// [`FieldMask`]: lance_graph_contract::class_view::FieldMask + /// [`WideFieldMask`]: lance_graph_contract::class_view::WideFieldMask + /// /// [`parse`] validates only that this is `0x`-prefixed hex fitting u32. /// It deliberately does **not** police the halves: a zero canon is a /// legal dormant state under the zero-fallback ladder, and pre-flip