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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 19:54:42 +02:00
commit 1afd942be3
5 changed files with 118 additions and 31 deletions

17
Cargo.lock generated
View file

@ -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"

View file

@ -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

View file

@ -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) {

View file

@ -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())

View file

@ -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<Item = &'a super::SubjectState>,
) -> 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<ImportKeyFault> {
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 {