From f278708a422b44a3a434ed36806b834f17017287 Mon Sep 17 00:00:00 2001 From: danvoulez Date: Mon, 18 May 2026 15:17:20 +0100 Subject: [PATCH 1/2] Enforce LIP-0008 ingress legitimacy in validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend AdmissibilityContext with ingress_tier and ingress_grammar (both Option, default None — legacy callers untouched). - Add validate_ingress_context: enforces the (None,None) / (Some,Some) / half-context discipline and the GrammarKind::admits matrix. - Modify validate_capability to filter manifests by LIP-0008 ingress acceptance (no early fail across multiple manifests). - Add three ValidationError variants: IncompleteIngressContext TierGrammarIllegitimate NoCapabilityForIngress - Map all three into RuntimeFailure in the planning compiler's back-compat projector with ingress-specific reason codes. - Add ..Default::default() to two test-helper AdmissibilityContext literals so the new fields adopt None. Tests: tier_grammar_validation.rs — 10 cases covering legacy both-None, half-context rejection, constitutional matrix, per-manifest restriction without alternative, multi-manifest with one accepting, legacy manifest permissiveness, explicit acceptance. Lib regression: 141/141, unchanged. Tracks LIP-0008 (Proposed). Stacked on add-lip-0008-types (PR #1). Co-Authored-By: Claude Opus 4.7 --- src/lib.rs | 4 +- src/plan_executor.rs | 1 + src/planning_compiler.rs | 24 ++++ src/validation.rs | 107 ++++++++++++++ tests/tier_grammar_validation.rs | 234 +++++++++++++++++++++++++++++++ 5 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 tests/tier_grammar_validation.rs diff --git a/src/lib.rs b/src/lib.rs index ca2cbf3..ea7c00c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -77,8 +77,8 @@ pub use strong_grammar::{ }; pub use tier::{GrammarKind, LlmTier}; pub use validation::{ - check_capability, validate_admissibility, validate_capability, validate_policy, - validate_structure, AdmissibilityContext, AdmissibleNode, ValidationError, + check_capability, validate_admissibility, validate_capability, validate_ingress_context, + validate_policy, validate_structure, AdmissibilityContext, AdmissibleNode, ValidationError, MAX_ROUTE_NESTING_DEPTH, }; diff --git a/src/plan_executor.rs b/src/plan_executor.rs index 271ec4f..0b4308c 100644 --- a/src/plan_executor.rs +++ b/src/plan_executor.rs @@ -464,6 +464,7 @@ mod tests { runtime_permitted: true, at_execution_boundary: true, require_evidence_closure: true, + ..Default::default() } } diff --git a/src/planning_compiler.rs b/src/planning_compiler.rs index f4bb0e0..70f55ce 100644 --- a/src/planning_compiler.rs +++ b/src/planning_compiler.rs @@ -664,6 +664,29 @@ fn validation_error_to_runtime_failure( reason_code: "capability_unsatisfied".into(), detail: msg.clone(), }, + // LIP-0008 ingress errors fold into the Validation stage with + // ingress-specific reason codes. Detailed mapping into a dedicated + // RuntimeFailure variant is left for a later PR; preserving the + // back-compat shape keeps this patch minimal. + ValidationError::IncompleteIngressContext { .. } => RuntimeFailure::Validation { + at: "ingress".into(), + field: None, + detail: e.to_string(), + reason_code: "ingress_incomplete".into(), + }, + ValidationError::TierGrammarIllegitimate { .. } => RuntimeFailure::Validation { + at: "ingress".into(), + field: None, + detail: e.to_string(), + reason_code: "tier_grammar_illegitimate".into(), + }, + ValidationError::NoCapabilityForIngress { .. } => RuntimeFailure::Capability { + primitive: format!("{:?}", PrimitiveName::from_primitive(&node.body)), + kind: primitive_kind(&node.body).map(str::to_owned), + attempted_substrate: None, + reason_code: "no_capability_for_ingress".into(), + detail: e.to_string(), + }, } } @@ -745,6 +768,7 @@ mod tests { runtime_permitted: true, at_execution_boundary: true, require_evidence_closure: true, + ..Default::default() } } diff --git a/src/validation.rs b/src/validation.rs index ac27996..914c434 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -3,6 +3,7 @@ use crate::capability::{primitive_kind, CapabilityManifest, PrimitiveName}; use crate::ir::{IRPrimitive, IrNode}; use crate::policy::PolicyClass; +use crate::tier::{GrammarKind, LlmTier}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -19,6 +20,16 @@ pub struct AdmissibilityContext { pub at_execution_boundary: bool, /// If true, substrates must declare `evidence.write` in [`CapabilityManifest::declared_guarantees`]. pub require_evidence_closure: bool, + /// LIP-0008: which LLM tier emitted the ingress that produced this node. + /// `None` together with `ingress_grammar = None` means legacy caller — + /// LIP-0008 checks are skipped. `Some` requires `ingress_grammar` to also + /// be `Some`; half-context is rejected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ingress_tier: Option, + /// LIP-0008: which grammar carried the ingress. Must be `Some` whenever + /// `ingress_tier` is `Some` (and vice versa). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ingress_grammar: Option, } impl Default for AdmissibilityContext { @@ -28,6 +39,8 @@ impl Default for AdmissibilityContext { runtime_permitted: true, at_execution_boundary: true, require_evidence_closure: true, + ingress_tier: None, + ingress_grammar: None, } } } @@ -46,6 +59,32 @@ pub enum ValidationError { Policy(String), #[error("capability: {0}")] Capability(String), + /// LIP-0008: ingress context carries one side of (tier, grammar) but not + /// the other. Legacy callers leave both `None`; LIP-0008-aware callers + /// must set both. + #[error( + "ingress: incomplete LIP-0008 context (tier_present={ingress_tier_present}, grammar_present={ingress_grammar_present}); set both or neither" + )] + IncompleteIngressContext { + ingress_tier_present: bool, + ingress_grammar_present: bool, + }, + /// LIP-0008: constitutional rule violated — this grammar does not admit + /// this tier per the matrix in `GrammarKind::admits`. + #[error("ingress: grammar {grammar:?} does not admit tier {tier:?} per LIP-0008")] + TierGrammarIllegitimate { tier: LlmTier, grammar: GrammarKind }, + /// LIP-0008: no capability manifest accepts the declared ingress + /// (tier, grammar) for this primitive. At least one manifest must allow + /// the tier (or have `allowed_ingress_tiers = None`, treated as legacy + /// permissive) AND allow the grammar (same legacy rule for `None`). + #[error( + "ingress: no capability manifest accepts ingress tier={tier:?} grammar={grammar:?} for primitive {primitive:?}" + )] + NoCapabilityForIngress { + primitive: PrimitiveName, + tier: Option, + grammar: Option, + }, } fn max_nesting_depth(prim: &IRPrimitive) -> usize { @@ -239,6 +278,7 @@ pub fn validate_capability( )); } let mut any = false; + let mut any_realizable = false; for m in manifests { if !m.can_realize(&node.body) { continue; @@ -249,10 +289,25 @@ pub fn validate_capability( if !m.evidence_realizable(ctx.require_evidence_closure) { continue; } + any_realizable = true; + if !manifest_accepts_ingress(m, ctx) { + continue; + } any = true; break; } if !any { + // If at least one manifest could realize the primitive but none + // accepted the declared ingress, surface the LIP-0008-specific + // error so callers can distinguish "wrong substrate" from + // "no substrate" and from "ingress mismatch". + if any_realizable && (ctx.ingress_tier.is_some() || ctx.ingress_grammar.is_some()) { + return Err(ValidationError::NoCapabilityForIngress { + primitive: PrimitiveName::from_primitive(&node.body), + tier: ctx.ingress_tier, + grammar: ctx.ingress_grammar, + }); + } return Err(ValidationError::Capability(format!( "no substrate can realize {:?} with kind={:?} and evidence requirements (manifests checked: {})", PrimitiveName::from_primitive(&node.body), @@ -271,10 +326,62 @@ pub fn validate_admissibility( ) -> Result { validate_structure(node)?; validate_policy(node, ctx)?; + validate_ingress_context(ctx)?; validate_capability(node, manifests, ctx)?; Ok(AdmissibleNode { node: node.clone() }) } +/// LIP-0008: enforce the constitutional matrix on ingress tier × grammar. +/// +/// - `(None, None)` is the legacy / unspecified path; LIP-0008 checks are +/// skipped entirely. +/// - `(Some, Some)` triggers `GrammarKind::admits`. +/// - `(Some, None)` and `(None, Some)` are rejected as half-context. +pub fn validate_ingress_context(ctx: &AdmissibilityContext) -> Result<(), ValidationError> { + match (&ctx.ingress_tier, &ctx.ingress_grammar) { + (None, None) => Ok(()), + (Some(tier), Some(grammar)) => { + if grammar.admits(tier) { + Ok(()) + } else { + Err(ValidationError::TierGrammarIllegitimate { + tier: *tier, + grammar: *grammar, + }) + } + } + (tier, grammar) => Err(ValidationError::IncompleteIngressContext { + ingress_tier_present: tier.is_some(), + ingress_grammar_present: grammar.is_some(), + }), + } +} + +/// LIP-0008 per-manifest ingress filter. +/// +/// A manifest **accepts** the ingress pair if, for each of `ingress_tier` +/// and `ingress_grammar` that the context declares, either (a) the manifest +/// declared `None` (legacy, treated as permissive) or (b) the manifest's +/// declared set contains the value. Context with `None` on a side never +/// constrains the manifest on that side. +fn manifest_accepts_ingress(m: &CapabilityManifest, ctx: &AdmissibilityContext) -> bool { + if let Some(tier) = &ctx.ingress_tier { + if let Some(allowed) = &m.allowed_ingress_tiers { + if !allowed.contains(tier) { + return false; + } + } + } + if let Some(grammar) = &ctx.ingress_grammar { + if let Some(allowed) = &m.allowed_grammars { + if !allowed.contains(grammar) { + return false; + } + } + } + true +} + /// Back-compat: single-manifest capability check. pub fn check_capability( manifest: &CapabilityManifest, diff --git a/tests/tier_grammar_validation.rs b/tests/tier_grammar_validation.rs new file mode 100644 index 0000000..e1bc788 --- /dev/null +++ b/tests/tier_grammar_validation.rs @@ -0,0 +1,234 @@ +//! LIP-0008 ingress enforcement in validation. +//! +//! Exercises the three-pass admissibility against the new LIP-0008 rules: +//! +//! 1. Constitutional matrix on `(ingress_tier, ingress_grammar)`. +//! 2. Half-context rejection (one side `Some`, the other `None`). +//! 3. Per-manifest filter that does NOT fail early — only fails when no +//! manifest survives `can_realize` ∧ `kind_allowed` ∧ +//! `evidence_realizable` ∧ `manifest_accepts_ingress`. + +use std::collections::BTreeSet; + +use constitutional_runtime::{ + validate_admissibility, AdmissibilityContext, CapabilityManifest, GrammarKind, IRPrimitive, + IrNode, LlmTier, NodeId, PolicyClass, PrimitiveName, TargetRef, ValidationError, +}; + +fn observe_node() -> IrNode { + IrNode { + id: NodeId("n0".to_string()), + body: IRPrimitive::Observe { + target: TargetRef("subject".to_string()), + scope: "default".to_string(), + }, + } +} + +fn base_ctx() -> AdmissibilityContext { + AdmissibilityContext { + policy_class: PolicyClass::C, + runtime_permitted: true, + at_execution_boundary: true, + require_evidence_closure: false, + ingress_tier: None, + ingress_grammar: None, + } +} + +fn manifest( + substrate_id: &str, + tiers: Option>, + grammars: Option>, +) -> CapabilityManifest { + CapabilityManifest { + substrate_id: substrate_id.into(), + substrate_version: "1".into(), + supported_primitives: BTreeSet::from_iter([PrimitiveName::Observe]), + supported_kinds: BTreeSet::new(), + declared_guarantees: BTreeSet::new(), + bindings: Vec::new(), + allowed_ingress_tiers: tiers, + allowed_grammars: grammars, + } +} + +// 1. Legacy: both context fields None → no LIP-0008 check applied. +#[test] +fn legacy_both_none_passes() { + let node = observe_node(); + let ctx = base_ctx(); + let mfs = vec![manifest("any", None, None)]; + validate_admissibility(&node, &mfs, &ctx).expect("legacy both-None must pass"); +} + +// 2a. Tier-only present is half-context → IncompleteIngressContext. +#[test] +fn incomplete_tier_only_fails() { + let node = observe_node(); + let mut ctx = base_ctx(); + ctx.ingress_tier = Some(LlmTier::Mini); + let mfs = vec![manifest("any", None, None)]; + let err = validate_admissibility(&node, &mfs, &ctx).unwrap_err(); + match err { + ValidationError::IncompleteIngressContext { + ingress_tier_present, + ingress_grammar_present, + } => { + assert!(ingress_tier_present); + assert!(!ingress_grammar_present); + } + other => panic!("expected IncompleteIngressContext, got {other:?}"), + } +} + +// 2b. Grammar-only present is half-context → IncompleteIngressContext. +#[test] +fn incomplete_grammar_only_fails() { + let node = observe_node(); + let mut ctx = base_ctx(); + ctx.ingress_grammar = Some(GrammarKind::Operational); + let mfs = vec![manifest("any", None, None)]; + let err = validate_admissibility(&node, &mfs, &ctx).unwrap_err(); + match err { + ValidationError::IncompleteIngressContext { + ingress_tier_present, + ingress_grammar_present, + } => { + assert!(!ingress_tier_present); + assert!(ingress_grammar_present); + } + other => panic!("expected IncompleteIngressContext, got {other:?}"), + } +} + +// 3. Constitutional matrix: (Mini, Strong) is illegitimate. +#[test] +fn mini_plus_strong_fails_constitutionally() { + let node = observe_node(); + let mut ctx = base_ctx(); + ctx.ingress_tier = Some(LlmTier::Mini); + ctx.ingress_grammar = Some(GrammarKind::Strong); + let mfs = vec![manifest("any", None, None)]; + let err = validate_admissibility(&node, &mfs, &ctx).unwrap_err(); + match err { + ValidationError::TierGrammarIllegitimate { tier, grammar } => { + assert_eq!(tier, LlmTier::Mini); + assert_eq!(grammar, GrammarKind::Strong); + } + other => panic!("expected TierGrammarIllegitimate, got {other:?}"), + } +} + +// 4. Constitutional matrix: (Operator, Strong) is legitimate. +#[test] +fn operator_plus_strong_passes_constitutionally() { + let node = observe_node(); + let mut ctx = base_ctx(); + ctx.ingress_tier = Some(LlmTier::Operator); + ctx.ingress_grammar = Some(GrammarKind::Strong); + let mfs = vec![manifest("any", None, None)]; + validate_admissibility(&node, &mfs, &ctx) + .expect("(Operator, Strong) is constitutionally legitimate"); +} + +// 5. Per-manifest tier rejection without alternative → NoCapabilityForIngress. +#[test] +fn manifest_restricts_tier_fails_when_no_alternative() { + let node = observe_node(); + let mut ctx = base_ctx(); + ctx.ingress_tier = Some(LlmTier::Mini); + ctx.ingress_grammar = Some(GrammarKind::Operational); + // Constitutional matrix passes (Operational admits Mini), but the only + // manifest restricts ingress to {Operator}, so capability fails. + let mfs = vec![manifest( + "operator_only", + Some(BTreeSet::from_iter([LlmTier::Operator])), + None, + )]; + let err = validate_admissibility(&node, &mfs, &ctx).unwrap_err(); + match err { + ValidationError::NoCapabilityForIngress { + primitive, + tier, + grammar, + } => { + assert_eq!(primitive, PrimitiveName::Observe); + assert_eq!(tier, Some(LlmTier::Mini)); + assert_eq!(grammar, Some(GrammarKind::Operational)); + } + other => panic!("expected NoCapabilityForIngress, got {other:?}"), + } +} + +// 6. Per-manifest grammar rejection without alternative → NoCapabilityForIngress. +#[test] +fn manifest_restricts_grammar_fails_when_no_alternative() { + let node = observe_node(); + let mut ctx = base_ctx(); + ctx.ingress_tier = Some(LlmTier::Operator); + ctx.ingress_grammar = Some(GrammarKind::Strong); + let mfs = vec![manifest( + "operational_only", + None, + Some(BTreeSet::from_iter([GrammarKind::Operational])), + )]; + let err = validate_admissibility(&node, &mfs, &ctx).unwrap_err(); + match err { + ValidationError::NoCapabilityForIngress { grammar, .. } => { + assert_eq!(grammar, Some(GrammarKind::Strong)); + } + other => panic!("expected NoCapabilityForIngress, got {other:?}"), + } +} + +// 7. Multi-manifest: one rejects ingress, another accepts → passes (no early fail). +#[test] +fn multi_manifest_one_accepts_passes() { + let node = observe_node(); + let mut ctx = base_ctx(); + ctx.ingress_tier = Some(LlmTier::Mini); + ctx.ingress_grammar = Some(GrammarKind::Operational); + let mfs = vec![ + manifest( + "rejects_mini", + Some(BTreeSet::from_iter([LlmTier::Operator])), + None, + ), + manifest( + "accepts_mini", + Some(BTreeSet::from_iter([LlmTier::Mini, LlmTier::Operator])), + None, + ), + ]; + validate_admissibility(&node, &mfs, &ctx) + .expect("multi-manifest must not fail early when an alternative accepts the ingress"); +} + +// 8. Legacy manifest (None+None) is permissive when context declares ingress. +#[test] +fn legacy_manifest_passes_when_context_declares_ingress() { + let node = observe_node(); + let mut ctx = base_ctx(); + ctx.ingress_tier = Some(LlmTier::Translator); + ctx.ingress_grammar = Some(GrammarKind::Strong); + let mfs = vec![manifest("legacy_permissive", None, None)]; + validate_admissibility(&node, &mfs, &ctx) + .expect("manifest with allowed_*=None is treated as legacy permissive"); +} + +// 9. Manifest that explicitly accepts the (tier, grammar) → passes. +#[test] +fn manifest_explicitly_accepts_passes() { + let node = observe_node(); + let mut ctx = base_ctx(); + ctx.ingress_tier = Some(LlmTier::Operator); + ctx.ingress_grammar = Some(GrammarKind::Strong); + let mfs = vec![manifest( + "explicit_operator_strong", + Some(BTreeSet::from_iter([LlmTier::Operator])), + Some(BTreeSet::from_iter([GrammarKind::Strong])), + )]; + validate_admissibility(&node, &mfs, &ctx) + .expect("manifest that explicitly accepts (tier, grammar) must pass"); +} From 0b91898b865db82a2f36b39ec9d9fccac84fcb8e Mon Sep 17 00:00:00 2001 From: danvoulez Date: Mon, 18 May 2026 15:26:09 +0100 Subject: [PATCH 2/2] Document LIP-0008 validation migration semantics - Mention LIP-0008 tier/grammar support in README - Mention LIP-0008 support in crate docs - Mark legacy permissive manifest behavior as migration bridge - No behavior changes Co-Authored-By: Claude Opus 4.7 --- README.md | 11 +++++++++++ src/lib.rs | 3 +++ src/validation.rs | 8 ++++++++ 3 files changed, 22 insertions(+) diff --git a/README.md b/README.md index 6012898..847f07d 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,17 @@ The IR knows nothing about specific labs, hosts, or operators. `InferSurface::Na `StandardRuntimeLowerer` is the default lowerer. Applications may implement the `Lowerer` trait for substrate-specific targets without forking this crate. +## LIP-0008 — LLM tier and dossier discipline + +The runtime can represent and validate LLM ingress tier × grammar discipline: +Mini/Operator/Translator/Frontier tiers and Operational/Strong/Dossier grammars. + +Current support is intentionally narrow: the runtime validates declared ingress +legitimacy and manifest acceptance. It does not call LLMs, dispatch work, or +close evidence. + +See the [LIP-0008 spec](https://github.com/LogLine-Foundation/governance/blob/main/lips/LIP-0008-llm-tier-discipline-and-dossier-discipline.md) in the governance repo for the constitutional rule the runtime obeys. + ## Where it sits in the LogLine ecosystem ``` diff --git a/src/lib.rs b/src/lib.rs index ea7c00c..c91282f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,9 @@ //! Execution is not sovereign: material actions must be semantically admissible, //! policy-permitted, capability-realizable, and evidentially accountable. //! +//! LIP-0008 support: represents LLM ingress tiers, grammar kinds, dossiers, +//! and validates declared tier × grammar legitimacy during admissibility. +//! //! See `docs/runtime/constitutional-runtime.md` for the full definition. pub mod act_identity; diff --git a/src/validation.rs b/src/validation.rs index 914c434..1b36ab2 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -364,6 +364,14 @@ pub fn validate_ingress_context(ctx: &AdmissibilityContext) -> Result<(), Valida /// declared `None` (legacy, treated as permissive) or (b) the manifest's /// declared set contains the value. Context with `None` on a side never /// constrains the manifest on that side. +/// +/// DOCTRINAL TODO (resolved when LIP-0008 transitions Proposed → Accepted): +/// while the LIP is Proposed, `allowed_*: None` on a manifest is treated +/// as legacy permissive — it accepts any ingress so callers can migrate. +/// When LIP-0008 is Accepted, the foundation must decide whether `None` +/// stays permissive (formal migration window) or becomes a Ghost / +/// validation error requiring explicit declaration. Legacy permissiveness +/// is a bridge, not a final constitution. fn manifest_accepts_ingress(m: &CapabilityManifest, ctx: &AdmissibilityContext) -> bool { if let Some(tier) = &ctx.ingress_tier { if let Some(allowed) = &m.allowed_ingress_tiers {