From 1afd942be3fe5e83f9a2b2f9e41e531965f4dc2b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 19:54:42 +0200 Subject: [PATCH] Ghostlight import key: one canonical predicate, one uniqueness function, state gate The record key is canonical free text per ruling import-key-canonical-text (NFC, no Cc/Cf, only U+0020 whitespace, no edge or doubled space). The resolver, admit_resolved and verify_state_shape all call the same predicate and import_key_fault; admit_resolved's second held-set iteration is deleted. Adds unicode-normalization and unicode-properties. Not yet verified on Yggdrasil; build status of this commit unproven. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 17 ++++++ crates/ghostlight/Cargo.toml | 2 + crates/ghostlight/src/journal.rs | 11 ++++ crates/ghostlight/src/lib.rs | 28 +++++----- crates/ghostlight/src/patch.rs | 91 +++++++++++++++++++++++++------- 5 files changed, 118 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 892fe21..b1e2e0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -676,6 +676,8 @@ dependencies = [ "thiserror", "tokio", "tracing", + "unicode-normalization", + "unicode-properties", "uuid", "zeroize", ] @@ -2137,6 +2139,21 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" diff --git a/crates/ghostlight/Cargo.toml b/crates/ghostlight/Cargo.toml index 765334b..7a643a6 100644 --- a/crates/ghostlight/Cargo.toml +++ b/crates/ghostlight/Cargo.toml @@ -19,6 +19,8 @@ sha2.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true +unicode-normalization = "0.1.25" +unicode-properties = "0.1.4" uuid.workspace = true zeroize.workspace = true diff --git a/crates/ghostlight/src/journal.rs b/crates/ghostlight/src/journal.rs index a9c7803..8163d3a 100644 --- a/crates/ghostlight/src/journal.rs +++ b/crates/ghostlight/src/journal.rs @@ -826,6 +826,17 @@ pub(super) fn verify_state_shape(state: &WorldState) -> Result<(), JournalError> "one subject holds two overlapping jurisdictions of one kind".into(), )); } + let mut declared_keys = BTreeSet::new(); + let no_held = BTreeSet::new(); + for subject in state.subjects.values() { + if let Some(key) = &subject.import_key + && super::patch::import_key_fault(key, &no_held, &mut declared_keys).is_some() + { + return Err(JournalError::Corrupt( + "subject import key is malformed or held by two subjects".into(), + )); + } + } let mut controller_ids = BTreeSet::new(); for (subject_id, subject) in &state.subjects { if !super::patch::is_canonical_text(&subject.label) { diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 242f553..b047595 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -2132,6 +2132,21 @@ fn admit_resolved( )); } } + // The subjects map's only writer holds the same injectivity the resolver + // checks, decided by the same function. + { + let held = patch::held_import_keys(state.subjects.values()); + let mut declared = BTreeSet::new(); + for subject in &resolved.subjects { + if let Some(key) = &subject.subject.import_key + && patch::import_key_fault(key, &held, &mut declared).is_some() + { + return Err(KernelError::Invariant( + "admitted subject's import key is malformed, held or repeated".into(), + )); + } + } + } for subject in &resolved.subjects { let scope = DecisionScope { subject_id: subject.subject_id, @@ -2177,19 +2192,6 @@ fn admit_resolved( "admitted controller ID collision".into(), )); } - // The subjects map's only writer holds the same injectivity the - // resolver checks, so no resolved effect can install a second - // subject for one record. - if subject.subject.import_key.is_some() - && state - .subjects - .values() - .any(|held| held.import_key == subject.subject.import_key) - { - return Err(KernelError::Invariant( - "admitted subject repeats a held import key".into(), - )); - } if state .subjects .insert(subject.subject_id, subject.subject.clone()) diff --git a/crates/ghostlight/src/patch.rs b/crates/ghostlight/src/patch.rs index 81b99cc..5385b4a 100644 --- a/crates/ghostlight/src/patch.rs +++ b/crates/ghostlight/src/patch.rs @@ -23,6 +23,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet, btree_map::Entry}; +use unicode_properties::{GeneralCategory, UnicodeGeneralCategory}; use uuid::Uuid; #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] @@ -975,12 +976,69 @@ impl ImportKey { &self.record } - fn is_canonical(&self) -> bool { - [&self.schema, &self.record].into_iter().all(|part| { - !part.is_empty() - && part.len() <= MAX_IMPORT_KEY_PART_BYTES - && !part.chars().any(char::is_whitespace) + /// The one predicate for an import key, called by the resolver and by the + /// load-time state gate alike. The record part is canonical free text: not + /// empty, at most 200 bytes, no leading, trailing or doubled space, already NFC + /// (refused, never normalised, so the kernel derives no second spelling), + /// no control or format character, and no whitespace but U+0020. The schema + /// part is a schema id: the same text with no space at all. + pub(super) fn is_canonical(&self) -> bool { + is_canonical_key_text(&self.record) + && is_canonical_key_text(&self.schema) + && !self.schema.contains(' ') + } +} + +fn is_canonical_key_text(part: &str) -> bool { + !part.is_empty() + && part.len() <= MAX_IMPORT_KEY_PART_BYTES + && !part.starts_with(' ') + && !part.ends_with(' ') + && !part.contains(" ") + && unicode_normalization::is_nfc(part) + && part.chars().all(|c| { + !c.is_control() + && c.general_category() != GeneralCategory::Format + && (c == ' ' || !c.is_whitespace()) }) +} + +/// Why an import key cannot join a world: the resolver names these by draft +/// handle, the state gates by invariant text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ImportKeyFault { + Malformed, + Held, + Duplicate, +} + +/// The keys the subjects hold, retired ones included, so a key is never +/// reissued. Derived on demand; no stored index. +pub(super) fn held_import_keys<'a>( + subjects: impl IntoIterator, +) -> BTreeSet<&'a ImportKey> { + subjects + .into_iter() + .filter_map(|subject| subject.import_key.as_ref()) + .collect() +} + +/// The one decision on a key: malformed, held by a subject already in the +/// world, or already claimed in `declared` (which it joins). Every door that +/// admits or loads subjects calls this, so none can decide otherwise. +pub(super) fn import_key_fault<'a>( + key: &'a ImportKey, + held: &BTreeSet<&ImportKey>, + declared: &mut BTreeSet<&'a ImportKey>, +) -> Option { + if !key.is_canonical() { + Some(ImportKeyFault::Malformed) + } else if held.contains(key) { + Some(ImportKeyFault::Held) + } else if !declared.insert(key) { + Some(ImportKeyFault::Duplicate) + } else { + None } } @@ -3192,13 +3250,7 @@ pub(super) fn resolve_patch( kind_names.insert(kernel_speak_entry().kind); } - // Derived from the subjects the world holds, retired ones included, so a - // key is never reissued; no stored index. - let held_keys: BTreeSet<&ImportKey> = state - .subjects - .values() - .filter_map(|subject| subject.import_key.as_ref()) - .collect(); + let held_keys = held_import_keys(state.subjects.values()); let mut declared_keys: BTreeSet<&ImportKey> = BTreeSet::new(); for (position, declaration) in patch.declarations.iter().enumerate() { let (handle, label, kind) = match declaration { @@ -3243,12 +3295,15 @@ pub(super) fn resolve_patch( && let Some(key) = &subject.import_key { let handle = subject.handle.clone(); - if !key.is_canonical() { - mismatches.push(Mismatch::MalformedImportKey { handle }); - } else if held_keys.contains(key) { - mismatches.push(Mismatch::ImportKeyHeld { handle }); - } else if !declared_keys.insert(key) { - mismatches.push(Mismatch::DuplicateImportKey { handle }); + match import_key_fault(key, &held_keys, &mut declared_keys) { + Some(ImportKeyFault::Malformed) => { + mismatches.push(Mismatch::MalformedImportKey { handle }); + } + Some(ImportKeyFault::Held) => mismatches.push(Mismatch::ImportKeyHeld { handle }), + Some(ImportKeyFault::Duplicate) => { + mismatches.push(Mismatch::DuplicateImportKey { handle }); + } + None => {} } } if let Declaration::Affordance(affordance) = declaration {