Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions crates/lance-graph/examples/soa-config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -95,8 +95,33 @@ 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 —
# 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
Expand All @@ -120,7 +145,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
Expand All @@ -133,7 +158,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
Expand Down
155 changes: 131 additions & 24 deletions crates/lance-graph/src/soa_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,58 @@ 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** — 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.
///
/// # 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
/// 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".
Expand All @@ -111,6 +156,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`].
Expand Down Expand Up @@ -143,7 +189,7 @@ 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),
}

Expand All @@ -165,7 +211,10 @@ 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"
)
}
}
}
Expand All @@ -180,6 +229,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<u32> {
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:
Expand All @@ -190,7 +249,9 @@ 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,
/// - any unknown key, via `deny_unknown_fields` on both structs — a typo
/// must not silently default a field.
pub fn parse(yaml: &str) -> Result<SoaConfig, ConfigError> {
let config: SoaConfig =
serde_yaml::from_str(yaml).map_err(|e| ConfigError::Yaml(e.to_string()))?;
Expand Down Expand Up @@ -223,15 +284,15 @@ pub fn parse(yaml: &str) -> Result<SoaConfig, ConfigError> {
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())),
}
// 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()))?;
}

Ok(config)
Expand All @@ -255,6 +316,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<u32> {
parse_classid_hex(&self.classid)
}
}

/// Compute the versioned table name an [`OnExisting::NewVersion`] refresh
/// writes to. `berlin.lance` with nanos `N` becomes `berlin.<N>.lance`; a
/// table name with no `.lance` suffix gets `.<N>` appended.
Expand Down Expand Up @@ -282,12 +353,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
"#
}
Expand All @@ -302,7 +373,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);

Expand All @@ -321,7 +392,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);
Expand Down Expand Up @@ -358,10 +429,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()));
Expand All @@ -382,10 +453,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()));
Expand Down Expand Up @@ -420,7 +491,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"));
Expand All @@ -437,7 +508,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"));
Expand All @@ -459,7 +530,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());
}

Expand All @@ -477,6 +548,42 @@ bakes:
assert_eq!(err, ConfigError::BadClassid("0xZZ".to_string()));
}

/// **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");
Expand Down
Loading