Skip to main content

qualia_core_db/modalities/logic/shacl_extensions/
identity.rs

1//! Human-centric identity & data-rights SHACL extensions.
2//!
3//! Four structural enforcements the Webizen Sentinel needs, each grounded in the
4//! project's identity principles (`identifiers-not-identity`,
5//! `out-of-band-remainder-is-freedom`, `governance-topology-relational`):
6//!
7//! 1. **Identity as an enumerated state** — an identity is validated as a *bounded
8//!    set of cryptographically-attested identifiers* with a confidence *relation*,
9//!    never collapsed to one definitive identifier. A binding asserting certainty
10//!    (`confidence >= 1.0`) is a `DefinitiveCollapse` and is rejected: the
11//!    out-of-band remainder (the un-resolvable link to the natural person) is what
12//!    keeps the person free, so it is a hard invariant here, not an afterthought.
13//! 2. **Decentralized shape-target routing** — shapes are bound to storage *loci*
14//!    (personal data stores / peers); validation is dispatched to where the data
15//!    lives (local-first) instead of pulling everything into a central index.
16//! 3. **Real-time severity degradation** — off-grid, non-critical violations degrade
17//!    to non-blocking so a *partial* subgraph stays usable; `Critical` violations
18//!    (identity / consent / safety) never degrade — they fail closed. This mirrors
19//!    the deontic non-derogable rule.
20//! 4. **Verifiable-Credential-gated targets** — a SHACL target applies to a focus
21//!    node only when a *verified* W3C VC is presented about it (origin-authenticated
22//!    data-rights property validation; the VC layer checks the signature/expiry first).
23//!
24//! All four runtime predicates are **zero-heap**: bounded slices in, scalars / enums
25//! / caller `out` buffers out. The TTL/opcode emitters write constants or into a
26//! caller slice.
27
28use crate::verifiable_credential::Credential;
29use crate::webizen::SlgOpcode;
30
31// ── 1. Identity as an enumerated, cryptographically-attested state ──────────────
32
33/// Bounded number of identifier bindings an enumerated identity may carry.
34pub const MAX_IDENTITY_BINDINGS: usize = 32;
35
36/// The cryptographic scheme that attests an identifier binding.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum CryptoScheme {
39    Ed25519,
40    MlDsa65,
41    Blake3Commitment,
42    X25519,
43    /// No real crypto backing — does NOT count toward the attestation requirement.
44    Unknown,
45}
46
47/// One identifier in an enumerated identity: a handle, the crypto scheme attesting
48/// it, whether an attestation is actually present, and the confidence (strictly in
49/// `(0,1)`) that this identifier picks out the natural person.
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct IdentifierBinding {
52    /// `q_hash` of the identifier (DID, key id, handle, …).
53    pub identifier: u64,
54    pub scheme: CryptoScheme,
55    pub attested: bool,
56    pub confidence: f32,
57}
58
59/// The verdict of [`validate_enumerated_identity`].
60#[derive(Debug, Clone, Copy, PartialEq)]
61pub enum IdentityValidation {
62    /// Enough distinct, crypto-attested identifiers; identity stands as an enumerated
63    /// state. `aggregate_confidence` is the noisy-OR combination (always `< 1.0`).
64    Valid {
65        distinct: u16,
66        attested: u16,
67        aggregate_confidence: f32,
68    },
69    /// Too few distinct identifiers and/or crypto attestations.
70    Underdetermined { distinct: u16, attested: u16 },
71    /// A binding claims certainty (`confidence >= 1.0`) — REJECTED. Identity must
72    /// remain a confidence-relation; collapsing it to a definitive identifier
73    /// destroys the out-of-band remainder.
74    DefinitiveCollapse,
75}
76
77/// Validate an identity as an enumerated state over crypto-attested identifiers.
78///
79/// `min_distinct` distinct identifiers and `min_attested` crypto-attested bindings
80/// are required. A binding with `confidence >= 1.0` short-circuits to
81/// [`IdentityValidation::DefinitiveCollapse`]. Zero-heap (scans the bounded slice).
82pub fn validate_enumerated_identity(
83    bindings: &[IdentifierBinding],
84    min_distinct: u16,
85    min_attested: u16,
86) -> IdentityValidation {
87    let mut distinct = 0u16;
88    let mut attested = 0u16;
89    let mut not_prob = 1.0f32; // running product of (1 - confidence) for noisy-OR
90
91    for (i, b) in bindings.iter().enumerate() {
92        // certainty is a definitive collapse — reject outright.
93        if b.confidence >= 1.0 {
94            return IdentityValidation::DefinitiveCollapse;
95        }
96        // count distinct identifiers (first occurrence only).
97        let first_seen = !bindings[..i].iter().any(|p| p.identifier == b.identifier);
98        if first_seen {
99            distinct += 1;
100        }
101        // a binding counts as attested only with real crypto backing.
102        if b.attested && b.scheme != CryptoScheme::Unknown {
103            attested += 1;
104            not_prob *= 1.0 - b.confidence.clamp(0.0, 1.0);
105        }
106    }
107
108    if distinct < min_distinct || attested < min_attested {
109        return IdentityValidation::Underdetermined { distinct, attested };
110    }
111    IdentityValidation::Valid {
112        distinct,
113        attested,
114        aggregate_confidence: 1.0 - not_prob,
115    }
116}
117
118/// Emit the SHACL opcodes for the enumerated-identity shape into `out` (zero-heap);
119/// returns the count written. The richer semantic enforcement (collapse rejection,
120/// noisy-OR confidence) lives in [`validate_enumerated_identity`].
121pub fn enumerated_identity_opcodes(
122    min_distinct: u32,
123    min_attested: u32,
124    out: &mut [SlgOpcode],
125) -> usize {
126    let ops = [
127        SlgOpcode::CheckMinCount(min_distinct),
128        SlgOpcode::CheckMinCount(min_attested),
129    ];
130    let n = ops.len().min(out.len());
131    out[..n].copy_from_slice(&ops[..n]);
132    n
133}
134
135// ── 2. Decentralized shape-target routing ──────────────────────────────────────
136
137/// Bounded number of shape→locus routes the router holds.
138pub const MAX_SHAPE_ROUTES: usize = 256;
139
140/// A binding of a SHACL `shape` to a storage `locus` (a personal data store / peer)
141/// where its target nodes live. Validation is dispatched to the locus; data is never
142/// aggregated centrally.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub struct ShapeRoute {
145    pub shape: u64,
146    pub locus: u64,
147}
148
149/// Enumerate the distinct shapes that apply at `locus` (what a local store validates).
150/// Writes shape ids into `out`; returns the count. Zero-heap.
151pub fn shapes_for_locus(routes: &[ShapeRoute], locus: u64, out: &mut [u64]) -> usize {
152    let mut n = 0usize;
153    for r in routes {
154        if r.locus != locus || out[..n].contains(&r.shape) {
155            continue;
156        }
157        if n >= out.len() {
158            break;
159        }
160        out[n] = r.shape;
161        n += 1;
162    }
163    n
164}
165
166/// Enumerate the distinct loci a `shape` must be routed to (fan-out without pulling
167/// the data together). Writes locus ids into `out`; returns the count. Zero-heap.
168pub fn loci_for_shape(routes: &[ShapeRoute], shape: u64, out: &mut [u64]) -> usize {
169    let mut n = 0usize;
170    for r in routes {
171        if r.shape != shape || out[..n].contains(&r.locus) {
172            continue;
173        }
174        if n >= out.len() {
175            break;
176        }
177        out[n] = r.locus;
178        n += 1;
179    }
180    n
181}
182
183/// Whether `shape` is validated locally at `self_locus` (local-first dispatch).
184pub fn route_is_local(routes: &[ShapeRoute], shape: u64, self_locus: u64) -> bool {
185    routes
186        .iter()
187        .any(|r| r.shape == shape && r.locus == self_locus)
188}
189
190// ── 3. Real-time severity degradation ──────────────────────────────────────────
191
192/// SHACL result severity, ordered so `Critical` is the maximum (never degrades).
193#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
194pub enum ShaclSeverity {
195    Info,
196    Warning,
197    Violation,
198    /// Non-derogable: identity / consent / safety. Fails closed even off-grid.
199    Critical,
200}
201
202/// Whether the engine is online (strict) or off-grid (partial-utilization tolerant).
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum OperationMode {
205    Online,
206    OffGrid,
207}
208
209/// A SHACL shape violation against a focus node, with its severity.
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub struct ShapeViolation {
212    pub shape: u64,
213    pub focus_node: u64,
214    pub severity: ShaclSeverity,
215}
216
217/// The result of [`degrade_violations`].
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub struct DegradationOutcome {
220    /// Violations that still block (always `Critical`; off-grid, *only* `Critical`).
221    pub blocking: u16,
222    /// Violations downgraded to non-blocking for off-grid partial utilization.
223    pub degraded: u16,
224    /// `true` when nothing blocks — the (partial) subgraph may be used.
225    pub subgraph_usable: bool,
226}
227
228/// Apply real-time severity degradation. `Online` blocks on every `Violation`/
229/// `Critical` (nothing degrades). `OffGrid` degrades non-`Critical` violations to
230/// non-blocking so a partial subgraph stays usable; `Critical` never degrades.
231///
232/// Writes the post-degradation violations (with adjusted severity) into `out` and
233/// returns the outcome. Zero-heap.
234pub fn degrade_violations(
235    violations: &[ShapeViolation],
236    mode: OperationMode,
237    out: &mut [ShapeViolation],
238) -> DegradationOutcome {
239    let mut blocking = 0u16;
240    let mut degraded = 0u16;
241    let count = violations.len().min(out.len());
242
243    for (i, v) in violations.iter().take(count).enumerate() {
244        let mut adjusted = *v;
245        let blocks = match mode {
246            OperationMode::Online => v.severity >= ShaclSeverity::Violation,
247            OperationMode::OffGrid => {
248                if v.severity == ShaclSeverity::Critical {
249                    true
250                } else {
251                    // degrade a blocking violation down to a non-blocking warning.
252                    if v.severity >= ShaclSeverity::Violation {
253                        adjusted.severity = ShaclSeverity::Warning;
254                        degraded += 1;
255                    }
256                    false
257                }
258            }
259        };
260        if blocks {
261            blocking += 1;
262        }
263        out[i] = adjusted;
264    }
265
266    DegradationOutcome {
267        blocking,
268        degraded,
269        subgraph_usable: blocking == 0,
270    }
271}
272
273// ── 4. Verifiable-Credential-gated SHACL targets ───────────────────────────────
274
275/// A credential gate on a SHACL target: the shape applies to a focus node only when
276/// a verified VC about that node carries the required claim from an accepted issuer.
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub struct CredentialGate {
279    /// The SHACL shape this gate guards.
280    pub shape: u64,
281    /// The claim predicate the VC must assert about the subject.
282    pub required_claim_predicate: u64,
283    /// The required object value (`0` = any value of the predicate).
284    pub required_claim_object: u64,
285    /// The accepted issuer (`0` = any grounded issuer — the VC layer enforces grounding).
286    pub accepted_issuer: u64,
287}
288
289/// Decide whether a credential-gated SHACL target applies to `focus_node`, given an
290/// **already cryptographically-verified** credential (call
291/// [`crate::verifiable_credential::verify`] / `verify_grounded` first — this gates on
292/// a *verified* VC, it does not re-check the signature).
293///
294/// Requires: the credential's subject is the focus node; the issuer is accepted; and
295/// the credential carries a claim matching the required predicate/object. Zero-heap
296/// (scans the credential's caller-owned claim list).
297pub fn credential_gates_target(gate: &CredentialGate, focus_node: u64, vc: &Credential) -> bool {
298    if vc.subject != focus_node {
299        return false;
300    }
301    if gate.accepted_issuer != 0 && vc.issuer != gate.accepted_issuer {
302        return false;
303    }
304    vc.claims.iter().any(|q| {
305        q.predicate == gate.required_claim_predicate
306            && (gate.required_claim_object == 0 || q.object == gate.required_claim_object)
307    })
308}
309
310// ── SHACL TTL vocabulary for the identity / data-rights shapes ──────────────────
311
312/// SHACL shapes for human-centric identity & data rights.
313pub fn get_identity_shacl_ttl() -> &'static str {
314    r#"
315@prefix q42: <https://webizen.org/q42#> .
316@prefix sh: <http://www.w3.org/ns/shacl#> .
317@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
318
319# Identity is an ENUMERATED state over multiple cryptographically-attested
320# identifiers — never a single definitive identifier. The out-of-band remainder is
321# preserved: identifier confidence is a RELATION strictly in (0,1), never certainty.
322q42:EnumeratedIdentityShape a sh:NodeShape ;
323    sh:targetClass q42:Principal ;
324    sh:property [
325        sh:path q42:hasIdentifier ;
326        sh:minCount 2 ;
327        sh:message "An identity must enumerate at least two distinct identifiers; a single definitive identifier collapses the out-of-band remainder." ;
328    ] ;
329    sh:property [
330        sh:path q42:identifierAttestation ;
331        sh:minCount 1 ;
332        sh:nodeKind sh:BlankNodeOrIRI ;
333        sh:message "Each identifier must carry a cryptographic attestation (Ed25519 / ML-DSA-65 / BLAKE3 commitment)." ;
334    ] ;
335    sh:property [
336        sh:path q42:identifierConfidence ;
337        sh:datatype xsd:decimal ;
338        sh:minExclusive 0 ;
339        sh:maxExclusive 1 ;
340        sh:message "Identifier confidence is strictly in (0,1); certainty (1.0) is a definitive-collapse and is rejected." ;
341    ] .
342
343# Decentralized routing: a shape is bound to the locus where its targets live, so
344# validation is dispatched locally rather than aggregating personal data centrally.
345q42:ShapeRouteShape a sh:NodeShape ;
346    sh:targetClass q42:ShapeRoute ;
347    sh:property [
348        sh:path q42:routedToLocus ;
349        sh:minCount 1 ;
350        sh:message "Every shape route must name a storage locus; validation goes to the data, not the data to a central index." ;
351    ] .
352
353# A SHACL target gated by a presented, verified Verifiable Credential.
354q42:CredentialGatedTargetShape a sh:NodeShape ;
355    sh:targetSubjectsOf q42:presentsCredential ;
356    sh:property [
357        sh:path q42:presentsCredential ;
358        sh:minCount 1 ;
359        sh:message "This target requires a presented, verified Verifiable Credential." ;
360    ] .
361"#
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::verifiable_credential::Credential;
368    use crate::{q_hash, NQuin};
369
370    fn binding(
371        id: &str,
372        scheme: CryptoScheme,
373        attested: bool,
374        confidence: f32,
375    ) -> IdentifierBinding {
376        IdentifierBinding {
377            identifier: q_hash(id),
378            scheme,
379            attested,
380            confidence,
381        }
382    }
383
384    #[test]
385    fn enumerated_identity_valid_stays_below_certainty() {
386        let bindings = [
387            binding("did:key:a", CryptoScheme::Ed25519, true, 0.6),
388            binding("did:key:b", CryptoScheme::MlDsa65, true, 0.7),
389            binding("handle:c", CryptoScheme::Blake3Commitment, true, 0.5),
390        ];
391        match validate_enumerated_identity(&bindings, 2, 2) {
392            IdentityValidation::Valid {
393                distinct,
394                attested,
395                aggregate_confidence,
396            } => {
397                assert_eq!(distinct, 3);
398                assert_eq!(attested, 3);
399                // noisy-OR of 0.6/0.7/0.5 = 1 - 0.4*0.3*0.5 = 0.94, strictly < 1.
400                assert!(aggregate_confidence > 0.9 && aggregate_confidence < 1.0);
401            }
402            other => panic!("expected Valid, got {other:?}"),
403        }
404    }
405
406    #[test]
407    fn enumerated_identity_underdetermined_when_too_few() {
408        // Only one attested identifier; an Unknown-scheme binding does not count.
409        let bindings = [
410            binding("did:key:a", CryptoScheme::Ed25519, true, 0.6),
411            binding("guess:b", CryptoScheme::Unknown, true, 0.4),
412        ];
413        assert_eq!(
414            validate_enumerated_identity(&bindings, 2, 2),
415            IdentityValidation::Underdetermined {
416                distinct: 2,
417                attested: 1
418            }
419        );
420    }
421
422    #[test]
423    fn certainty_is_a_definitive_collapse() {
424        // A binding asserting certainty must be rejected — the out-of-band remainder
425        // is a hard invariant.
426        let bindings = [
427            binding("did:key:a", CryptoScheme::Ed25519, true, 0.6),
428            binding("gov:id", CryptoScheme::MlDsa65, true, 1.0),
429        ];
430        assert_eq!(
431            validate_enumerated_identity(&bindings, 1, 1),
432            IdentityValidation::DefinitiveCollapse
433        );
434    }
435
436    #[test]
437    fn decentralized_routing_dispatches_to_loci() {
438        let routes = [
439            ShapeRoute {
440                shape: 1,
441                locus: 100,
442            },
443            ShapeRoute {
444                shape: 2,
445                locus: 100,
446            },
447            ShapeRoute {
448                shape: 1,
449                locus: 200,
450            },
451            ShapeRoute {
452                shape: 1,
453                locus: 100,
454            }, // duplicate, must dedup
455        ];
456        let mut shapes = [0u64; 8];
457        let n = shapes_for_locus(&routes, 100, &mut shapes);
458        assert_eq!(n, 2);
459        assert!(shapes[..n].contains(&1) && shapes[..n].contains(&2));
460
461        let mut loci = [0u64; 8];
462        let m = loci_for_shape(&routes, 1, &mut loci);
463        assert_eq!(m, 2); // loci 100 and 200, deduped
464        assert!(loci[..m].contains(&100) && loci[..m].contains(&200));
465
466        assert!(route_is_local(&routes, 1, 100));
467        assert!(!route_is_local(&routes, 2, 200));
468    }
469
470    #[test]
471    fn severity_degradation_offgrid_keeps_partial_subgraph_usable() {
472        let violations = [
473            ShapeViolation {
474                shape: 1,
475                focus_node: 10,
476                severity: ShaclSeverity::Violation,
477            },
478            ShapeViolation {
479                shape: 2,
480                focus_node: 11,
481                severity: ShaclSeverity::Warning,
482            },
483        ];
484        let mut out = [violations[0]; 2];
485
486        // Online: the Violation blocks → subgraph not usable.
487        let online = degrade_violations(&violations, OperationMode::Online, &mut out);
488        assert_eq!(online.blocking, 1);
489        assert!(!online.subgraph_usable);
490
491        // Off-grid: the Violation degrades to Warning → nothing blocks → usable.
492        let offgrid = degrade_violations(&violations, OperationMode::OffGrid, &mut out);
493        assert_eq!(offgrid.blocking, 0);
494        assert_eq!(offgrid.degraded, 1);
495        assert!(offgrid.subgraph_usable);
496        assert_eq!(out[0].severity, ShaclSeverity::Warning); // downgraded in place
497    }
498
499    #[test]
500    fn critical_never_degrades_even_offgrid() {
501        let violations = [ShapeViolation {
502            shape: 9,
503            focus_node: 99,
504            severity: ShaclSeverity::Critical,
505        }];
506        let mut out = [violations[0]; 1];
507        let outcome = degrade_violations(&violations, OperationMode::OffGrid, &mut out);
508        assert_eq!(outcome.blocking, 1);
509        assert_eq!(outcome.degraded, 0);
510        assert!(!outcome.subgraph_usable); // fails closed
511        assert_eq!(out[0].severity, ShaclSeverity::Critical);
512    }
513
514    #[test]
515    fn verifiable_credential_gates_the_target() {
516        let alice = q_hash("did:example:alice");
517        let issuer = q_hash("did:example:gov");
518        let cap_pred = q_hash("https://ns.webcivics.net/capability/heldBy");
519        let cap_obj = q_hash("cap:LicensedPractitioner");
520
521        let claim = NQuin {
522            subject: alice,
523            predicate: cap_pred,
524            object: cap_obj,
525            context: 0,
526            metadata: 0,
527            parity: 0,
528        };
529        let vc = Credential {
530            issuer,
531            subject: alice,
532            issued_at: 1000,
533            valid_until: 0,
534            claims: vec![claim],
535        };
536        let gate = CredentialGate {
537            shape: 1,
538            required_claim_predicate: cap_pred,
539            required_claim_object: cap_obj,
540            accepted_issuer: issuer,
541        };
542
543        // Matches: right subject, issuer, and claim.
544        assert!(credential_gates_target(&gate, alice, &vc));
545        // Wrong focus node → does not apply.
546        assert!(!credential_gates_target(
547            &gate,
548            q_hash("did:example:bob"),
549            &vc
550        ));
551        // Wrong issuer → rejected.
552        let gate_other_issuer = CredentialGate {
553            accepted_issuer: q_hash("did:example:rogue"),
554            ..gate
555        };
556        assert!(!credential_gates_target(&gate_other_issuer, alice, &vc));
557        // Missing the required claim object → does not apply.
558        let gate_other_claim = CredentialGate {
559            required_claim_object: q_hash("cap:Something_Else"),
560            ..gate
561        };
562        assert!(!credential_gates_target(&gate_other_claim, alice, &vc));
563        // accepted_issuer = 0 means "any grounded issuer".
564        let gate_any_issuer = CredentialGate {
565            accepted_issuer: 0,
566            ..gate
567        };
568        assert!(credential_gates_target(&gate_any_issuer, alice, &vc));
569    }
570}