diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d08c96..5349083 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,10 @@ jobs: # bump deliberately when the typesec/Grust contract changes. If # querygraph/grust is private, also pass a token with read access: # token: ${{ secrets.GRUST_CHECKOUT_TOKEN }} - # typesec 0.12 (Torcello) tracks Grust 0.12 (Lobster), on main. - ref: main + # TypeSec 0.13.1 tracks the reviewed Grust 0.12.1 release candidate. + # Pin the commit so a moving or older `main` cannot silently change + # this cross-repository build contract. + ref: a178c30de9b5c194fae68c9bed37c6665e1096ad - name: Show toolchain (pinned by rust-toolchain.toml) working-directory: typesec diff --git a/CHANGELOG.md b/CHANGELOG.md index e251d99..2b7ea8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,86 @@ by release version, then by the date the logical change landed. ## Unreleased +- Add a closed semantic decision vocabulary and signed, immutable-model-bound + receipts for publication, consumption, field access, metric execution, + semantic queries, and AI-context access. + +- Align the Python graph-gate smoke test with the least-privilege + `company/*/org-graph` persistence grant used by the company policy. +- Correct the company-graph example's aggregate persistence grant to the + least-privilege `company/*/org-graph` shape. Regression tests now prove that + a graph administrator can persist a tenant org graph without gaining an + unrelated `company//payroll` write. +- Pass the company-graph live gate against QueryGraph's exact optimized + graph-enabled Sail `c5309365` artifact: both expected authorization denials + remain enforced and the permitted typed write persists 5 nodes and 4 edges. + The gate also drove regression coverage for Grust's schema-declared node + identity mapping without introducing a second Typesec-owned Sail pin. +- Pin the CI sibling checkout to the reviewed Grust 0.12.1 revision, matching + TypeSec's path-dependency contract instead of resolving against the older + 0.12.0 package versions on Grust's moving `main` branch. +- Receipt issuance now writes both base64url segments into one exactly sized + token allocation, verification decodes fixed-size signatures on the stack, + and sealed cognition receipt values avoid redundant semantic rescans after + their validating constructor or deserializer. With 256 affected IDs, + cognition receipt issue improves from about 49.7 to 43.4 microseconds and + verification from 77.4 to 72.3 microseconds, with byte-identical tokens. +- TypeDID canonical transcripts now share one sink-agnostic encoder for byte + vectors, exact length counting, and direct SHA-256 hashing. Envelope seal and + open reuse the authenticated header, while signed references no longer build + and copy nested ciphertext-sized vectors. A 64 KiB reference improves from + about 138.5 to 72.4 microseconds; after accelerated hex decoding, complete + open time improves further from about 632 to 576 microseconds (1.023 + milliseconds before both changes). +- DID hexadecimal decoding now uses one validated lookup per nibble while + retaining mixed-case input compatibility and canonical lowercase output. A + 64 KiB X25519/ChaCha20-Poly1305 envelope decrypt improves from about 684 to + 297 microseconds, reducing complete TypeDID open time from about 1.023 + milliseconds to 632 microseconds. +- In-memory DID replay protection now prunes claims through an expiry-ordered + queue instead of scanning every active claim while holding its mutex. At + 10,000 active claims, replay hits improve from about 14.1 microseconds to + 25.9 nanoseconds, while an eight-thread burst improves from about 60,300 to + 3.86 million claims per second; the one-entry path rises from 24.2 to 25.9 + nanoseconds. +- Added production Criterion coverage for TypeDID replay protection, Ed25519 + signing, X25519/ChaCha20-Poly1305 encryption, complete envelope seal/open and + references, and decision and cognition receipt issue/verification, including + replay-cache scaling and concurrent bursts. +- SHA-256 now enables its accelerated backend on supported targets. Production + benchmarks reduce a 64 KiB governed-draft digest from about 245 to 61 + microseconds and a 256-draft cognition-proposal digest from 1.421 + milliseconds to 593 microseconds, while retaining the portable fallback. +- Cognition identities now stream borrowed canonical binding and proposal + views instead of cloning protected draft payloads and digest strings. A + 256-field binding digest improves from about 28.4 to 11.1 microseconds and a + 256-draft proposal digest from 593 to 499 microseconds after SHA acceleration. +- Proposal validation now enforces the exact raw-wire byte ceiling while + streaming the canonical identity through one bounded buffered pass, then + carries that digest into commit preparation instead of serializing again. A + 256-draft digest improves further from about 499 to 372 microseconds. +- Keyword search now uses compact inverted postings for selective queries and + an adaptive document scan for dense queries, with stable internal document + keys and idempotent reindexing. Against 10,000 records, sparse top-10 search + improves from about 449 to 66 microseconds, dense search from 543 to 478 + microseconds, and a no-hit search from 497 microseconds to 216 nanoseconds. + Building the richer index rises from about 3.8 to 9.6 milliseconds and an + update that changes tokens from 271 to 624 nanoseconds. +- Memory stores and the keyword index now partition bounded top-k results in + linear time and sort only the retained results, while preserving deterministic + recency, score, and id ordering. Against 10,000 records, latest-10 queries + improve from about 1.277 milliseconds to 219 microseconds (82.8%), + case-insensitive filtered queries from 470 to 416 microseconds (11.4%), and + keyword top-10 search from 505 to 458 microseconds (9.3%). +- Added per-operation Criterion coverage for core policy composition, RBAC + grant scaling, indexed ODRL decisions, in-memory record queries, and keyword + search so authorization and memory-path regressions are measured at realistic + policy and record counts. +- Indexed RBAC resource grants by permission, removed per-decision ODRL lookup + key and candidate-vector allocations, and made in-memory query/search result + selection borrow records until after sorting and limiting. ASCII text filters + now perform case-insensitive matching without lowercase allocations while + retaining the prior Unicode lowercase behavior. - Prepare the `0.13.1` registry release so the current cognition and capability-memory APIs are consumable by released Marciana crates. - Align the release metadata with Grust `0.12.1`, the published graph, diff --git a/Cargo.toml b/Cargo.toml index af42139..079a024 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,7 +65,7 @@ glob = "0.3" ed25519-dalek = "2" x25519-dalek = { version = "2", features = ["static_secrets"] } chacha20poly1305 = "0.10" -sha2 = "0.10" +sha2 = { version = "0.10", features = ["asm"] } getrandom = "0.2" base64 = "0.22" jsonschema = { version = "0.26", default-features = false } diff --git a/crates/typesec-core/benches/policy_check.rs b/crates/typesec-core/benches/policy_check.rs index a5e3966..a6f7318 100644 --- a/crates/typesec-core/benches/policy_check.rs +++ b/crates/typesec-core/benches/policy_check.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use criterion::{Criterion, Throughput, black_box, criterion_group, criterion_main}; use typesec_core::{ CanRead, CanWrite, Capability, CombineStrategy, ComposedEngine, LatticeEngine, Permission, PolicyEngine, PolicyResult, Resource, ResourceId, SubjectId, policy::mint_capability, @@ -39,16 +39,17 @@ fn bench_mint_capability_allow(c: &mut Criterion) { let engine = AllowAll; let resource = GenericResource::new("reports/q1", "report"); - c.bench_function("bench_mint_capability_allow", |b| { + let mut group = c.benchmark_group("policy_core"); + group.throughput(Throughput::Elements(1)); + group.bench_function("mint_capability_allow", |b| { b.iter(|| { - for _ in 0..1_000 { - let cap: Capability = - mint_capability(&engine, black_box("agent:bench"), black_box(&resource)) - .expect("allow"); - black_box(cap); - } + let cap: Capability = + mint_capability(&engine, black_box("agent:bench"), black_box(&resource)) + .expect("allow"); + black_box(cap); }) }); + group.finish(); } fn bench_lattice_promotion(c: &mut Criterion) { @@ -57,17 +58,18 @@ fn bench_lattice_promotion(c: &mut Criterion) { let subject = SubjectId::from("agent:bench"); let resource_id = ResourceId::from(resource.resource_id()); - c.bench_function("bench_lattice_promotion", |b| { + let mut group = c.benchmark_group("policy_core"); + group.throughput(Throughput::Elements(1)); + group.bench_function("lattice_promotion", |b| { b.iter(|| { - for _ in 0..1_000 { - let _ = black_box(engine.check( - black_box(&subject), - black_box(CanRead::name()), - black_box(&resource_id), - )); - } + black_box(engine.check( + black_box(&subject), + black_box(CanRead::name()), + black_box(&resource_id), + )) }) }); + group.finish(); } fn bench_composed_engine_deny_overrides(c: &mut Criterion) { @@ -79,17 +81,18 @@ fn bench_composed_engine_deny_overrides(c: &mut Criterion) { let subject = SubjectId::from("agent:bench"); let resource_id = ResourceId::from(resource.resource_id()); - c.bench_function("bench_composed_engine_deny_overrides", |b| { + let mut group = c.benchmark_group("policy_core"); + group.throughput(Throughput::Elements(1)); + group.bench_function("composed_deny_overrides", |b| { b.iter(|| { - for _ in 0..1_000 { - let _ = black_box(engine.check( - black_box(&subject), - black_box(CanWrite::name()), - black_box(&resource_id), - )); - } + black_box(engine.check( + black_box(&subject), + black_box(CanWrite::name()), + black_box(&resource_id), + )) }) }); + group.finish(); } criterion_group!( diff --git a/crates/typesec-integrations/Cargo.toml b/crates/typesec-integrations/Cargo.toml index 0de5113..4426c7d 100644 --- a/crates/typesec-integrations/Cargo.toml +++ b/crates/typesec-integrations/Cargo.toml @@ -33,5 +33,10 @@ thiserror = { workspace = true } tracing = { workspace = true } [dev-dependencies] +criterion = { workspace = true } opentelemetry_sdk = { version = "0.30", default-features = false, features = ["trace", "testing"] } trybuild = "1" + +[[bench]] +name = "integration_paths" +harness = false diff --git a/crates/typesec-integrations/benches/integration_paths.rs b/crates/typesec-integrations/benches/integration_paths.rs new file mode 100644 index 0000000..e0568bc --- /dev/null +++ b/crates/typesec-integrations/benches/integration_paths.rs @@ -0,0 +1,405 @@ +use std::sync::Arc; + +use chrono::{TimeDelta, TimeZone, Utc}; +use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; +use ed25519_dalek::SigningKey; +use typesec_integrations::{ + CognitionCommitReceipt, CognitionCommitReceiptClaims, CognitionEffect, DecisionReceipt, Did, + DidEnvelope, DidKeyStore, DidMessageBody, Ed25519DidKey, Ed25519DidKeyStore, + InMemoryReplayStore, ReceiptIssuer, ReceiptVerifier, ReplayStore, StaticDidResolver, + TypeDidConversation, TypeDidGateway, TypeDidMode, TypeDidProfile, VerificationMethod, +}; + +const NOW: u64 = 1_800_000_000; +const EXPIRY: u64 = NOW + 300; +const PAYLOAD_SIZES: [usize; 2] = [256, 65_536]; + +struct DidFixture { + alice: Did, + agent: Did, + resolver: StaticDidResolver, + keys: Ed25519DidKeyStore, + alice_authentication: VerificationMethod, + alice_agreement_public: [u8; 32], + agent_agreement_public: [u8; 32], +} + +fn did_fixture() -> DidFixture { + let alice_key = Ed25519DidKey::from_seed(b"benchmark-alice-ed25519"); + let agent_key = Ed25519DidKey::from_seed(b"benchmark-agent-ed25519"); + let alice = Did::key(alice_key.signing_public()); + let agent = Did::key(agent_key.signing_public()); + let alice_document = alice_key.document(alice.clone()); + let agent_document = agent_key.document(agent.clone()); + let alice_authentication = alice_document.verification_method[0].clone(); + let alice_agreement_public = alice_key.agreement_public(); + let agent_agreement_public = agent_key.agreement_public(); + let resolver = StaticDidResolver::new() + .with_document(alice_document) + .with_document(agent_document); + let keys = Ed25519DidKeyStore::new() + .with_key(alice.clone(), alice_key) + .with_key(agent.clone(), agent_key); + DidFixture { + alice, + agent, + resolver, + keys, + alice_authentication, + alice_agreement_public, + agent_agreement_public, + } +} + +fn typedid_envelope(fixture: &DidFixture, payload: &[u8]) -> DidEnvelope { + DidEnvelope::typedid( + "benchmark-envelope", + fixture.alice.clone(), + fixture.agent.clone(), + DidMessageBody::agent_message("memory/benchmark", "secret") + .with_claim("purpose", "benchmark") + .with_claim("agent_id", "agent:benchmark"), + TypeDidConversation::new( + "benchmark-conversation", + TypeDidMode::RequestReply, + TypeDidProfile::ed25519_x25519_chacha20().id, + "a2a", + ), + payload, + &fixture.resolver, + &fixture.keys, + ) + .expect("seal benchmark envelope") +} + +#[derive(Debug)] +struct AcceptingReplayStore; + +impl ReplayStore for AcceptingReplayStore { + fn claim(&self, _: &str, _: u64, _: u64) -> Result { + Ok(true) + } +} + +fn populated_replay_store(entries: usize) -> Arc { + let store = Arc::new(InMemoryReplayStore::new()); + for index in 0..entries { + assert!( + store + .claim(&format!("active-envelope-{index}"), EXPIRY, NOW) + .expect("populate replay store") + ); + } + store +} + +fn bench_replay_store(c: &mut Criterion) { + let mut group = c.benchmark_group("did_replay_claim"); + group.sample_size(30); + for entries in [1, 1_000, 10_000] { + let store = populated_replay_store(entries); + group.throughput(Throughput::Elements(1)); + group.bench_with_input(BenchmarkId::new("active_hit", entries), &entries, |b, _| { + b.iter(|| { + black_box( + store + .claim(black_box("active-envelope-0"), EXPIRY, NOW) + .expect("check replay claim"), + ) + }) + }); + } + group.finish(); + + let store = populated_replay_store(10_000); + let mut concurrent = c.benchmark_group("did_replay_concurrent_burst"); + concurrent.sample_size(20); + for threads in [1, 8] { + const CLAIMS_PER_THREAD: usize = 128; + concurrent.throughput(Throughput::Elements((threads * CLAIMS_PER_THREAD) as u64)); + concurrent.bench_with_input( + BenchmarkId::from_parameter(threads), + &threads, + |b, &threads| { + b.iter(|| { + std::thread::scope(|scope| { + for _ in 0..threads { + let store = Arc::clone(&store); + scope.spawn(move || { + for _ in 0..CLAIMS_PER_THREAD { + black_box( + store + .claim("active-envelope-0", EXPIRY, NOW) + .expect("check concurrent replay claim"), + ); + } + }); + } + }); + }); + }, + ); + } + concurrent.finish(); +} + +fn bench_did_crypto(c: &mut Criterion) { + let fixture = did_fixture(); + let nonce = [7_u8; 12]; + let associated_data = b"typesec integration benchmark associated data"; + + let mut signing = c.benchmark_group("did_ed25519"); + signing.sample_size(30); + for payload_bytes in PAYLOAD_SIZES { + let payload = vec![b'x'; payload_bytes]; + let signature = fixture + .keys + .sign(&fixture.alice, &payload) + .expect("sign benchmark payload"); + signing.throughput(Throughput::Bytes(payload_bytes as u64)); + signing.bench_with_input( + BenchmarkId::new("sign", payload_bytes), + &payload, + |b, payload| { + b.iter(|| { + black_box( + fixture + .keys + .sign(&fixture.alice, black_box(payload)) + .expect("sign benchmark payload"), + ) + }) + }, + ); + signing.bench_with_input( + BenchmarkId::new("verify", payload_bytes), + &payload, + |b, payload| { + b.iter(|| { + fixture + .keys + .verify( + &fixture.alice_authentication, + black_box(payload), + black_box(&signature), + ) + .expect("verify benchmark payload") + }) + }, + ); + } + signing.finish(); + + let mut encryption = c.benchmark_group("did_x25519_chacha20poly1305"); + encryption.sample_size(30); + for payload_bytes in PAYLOAD_SIZES { + let payload = vec![b'x'; payload_bytes]; + let ciphertext = fixture + .keys + .encrypt_for( + &fixture.alice, + &fixture.agent_agreement_public, + &payload, + &nonce, + associated_data, + ) + .expect("encrypt benchmark payload"); + encryption.throughput(Throughput::Bytes(payload_bytes as u64)); + encryption.bench_with_input( + BenchmarkId::new("encrypt", payload_bytes), + &payload, + |b, payload| { + b.iter(|| { + black_box( + fixture + .keys + .encrypt_for( + &fixture.alice, + &fixture.agent_agreement_public, + black_box(payload), + &nonce, + associated_data, + ) + .expect("encrypt benchmark payload"), + ) + }) + }, + ); + encryption.bench_with_input( + BenchmarkId::new("decrypt", payload_bytes), + &ciphertext, + |b, ciphertext| { + b.iter(|| { + black_box( + fixture + .keys + .decrypt_for( + &fixture.agent, + &fixture.alice_agreement_public, + &nonce, + black_box(ciphertext), + associated_data, + ) + .expect("decrypt benchmark payload"), + ) + }) + }, + ); + } + encryption.finish(); +} + +fn bench_typedid(c: &mut Criterion) { + let fixture = did_fixture(); + let mut group = c.benchmark_group("typedid_envelope"); + group.sample_size(30); + + for payload_bytes in PAYLOAD_SIZES { + let payload = vec![b'x'; payload_bytes]; + let envelope = typedid_envelope(&fixture, &payload); + let gateway = TypeDidGateway::new( + Arc::new(fixture.resolver.clone()), + Arc::new(fixture.keys.clone()), + fixture.agent.clone(), + ) + .with_replay_store(Arc::new(AcceptingReplayStore)); + group.throughput(Throughput::Bytes(payload_bytes as u64)); + group.bench_with_input( + BenchmarkId::new("seal", payload_bytes), + &payload, + |b, payload| b.iter(|| black_box(typedid_envelope(&fixture, black_box(payload)))), + ); + group.bench_with_input( + BenchmarkId::new("open", payload_bytes), + &envelope, + |b, envelope| { + b.iter(|| { + black_box( + gateway + .open_message(black_box(envelope)) + .expect("open benchmark envelope"), + ) + }) + }, + ); + group.bench_with_input( + BenchmarkId::new("reference", payload_bytes), + &envelope, + |b, envelope| b.iter(|| black_box(black_box(envelope).reference())), + ); + } + group.finish(); +} + +fn digest(fill: char) -> String { + format!("sha256:{}", fill.to_string().repeat(64)) +} + +fn cognition_receipt(affected_ids: usize) -> CognitionCommitReceipt { + let authority = Utc.with_ymd_and_hms(2026, 8, 5, 11, 59, 0).unwrap(); + let prepared = Utc.with_ymd_and_hms(2026, 8, 5, 12, 0, 0).unwrap(); + CognitionCommitReceipt::new( + CognitionCommitReceiptClaims { + effect: CognitionEffect::Mutated, + subject: "did:key:benchmark-agent".into(), + resource: "memory/did:key:benchmark-agent/research".into(), + job_id: "benchmark-job".into(), + governed_source_scope: Some(digest('a')), + typedid_request_digest: digest('1'), + proposal_digest: digest('2'), + governed_scan_digest: digest('3'), + input_snapshot_digest: digest('4'), + policy_decision_digest: digest('5'), + authorization_receipt_digest: digest('6'), + prior_version: "version-before".into(), + resulting_version: "version-after".into(), + affected_ids: (0..affected_ids) + .map(|index| format!("memory-{index:04}")) + .collect(), + backend_commit_id: "commit-benchmark".into(), + authority_revalidated_at: authority, + prepared_at: prepared, + committed_at: prepared + TimeDelta::seconds(1), + issued_at: prepared + TimeDelta::seconds(2), + }, + TimeDelta::minutes(5), + ) + .expect("construct benchmark cognition receipt") +} + +fn bench_receipts(c: &mut Criterion) { + let now = Utc.with_ymd_and_hms(2026, 8, 5, 12, 0, 3).unwrap(); + let issuer = ReceiptIssuer::new(SigningKey::from_bytes(&[11_u8; 32])); + let verifier = ReceiptVerifier::new(issuer.verifying_key()); + let decision = DecisionReceipt::new( + "did:key:benchmark-agent", + "memory:remember", + "memory/did:key:benchmark-agent/research", + now, + TimeDelta::minutes(5), + ) + .for_tool_call("remember", Some("call-benchmark")); + let decision_token = issuer.issue(&decision); + + let mut decisions = c.benchmark_group("decision_receipt"); + decisions.sample_size(30); + decisions.bench_function("issue", |b| { + b.iter(|| black_box(issuer.issue(black_box(&decision)))) + }); + decisions.bench_function("verify", |b| { + b.iter(|| { + black_box( + verifier + .verify(black_box(&decision_token), now) + .expect("verify benchmark decision receipt"), + ) + }) + }); + decisions.finish(); + + let mut cognition = c.benchmark_group("cognition_receipt"); + cognition.sample_size(30); + for affected_ids in [2, 256] { + let receipt = cognition_receipt(affected_ids); + let token = issuer + .issue_cognition(&receipt, now) + .expect("issue benchmark cognition receipt"); + cognition.throughput(Throughput::Elements(affected_ids as u64)); + cognition.bench_with_input( + BenchmarkId::new("issue", affected_ids), + &receipt, + |b, receipt| { + b.iter(|| { + black_box( + issuer + .issue_cognition(black_box(receipt), now) + .expect("issue benchmark cognition receipt"), + ) + }) + }, + ); + cognition.bench_with_input( + BenchmarkId::new("verify", affected_ids), + &token, + |b, token| { + b.iter(|| { + black_box( + verifier + .verify_cognition(black_box(token), now) + .expect("verify benchmark cognition receipt"), + ) + }) + }, + ); + } + cognition.finish(); +} + +criterion_group!( + benches, + bench_replay_store, + bench_did_crypto, + bench_typedid, + bench_receipts +); +criterion_main!(benches); diff --git a/crates/typesec-integrations/src/did/auth.rs b/crates/typesec-integrations/src/did/auth.rs index 0fe7824..4feee3f 100644 --- a/crates/typesec-integrations/src/did/auth.rs +++ b/crates/typesec-integrations/src/did/auth.rs @@ -2,6 +2,7 @@ use super::envelope::DidEnvelope; use super::typedid::TypeDidMode; +use sha2::Digest as _; /// Wire identifier for the only accepted envelope authentication protocol. pub const DID_ENVELOPE_AUTH_V2: &str = "typesec.did-envelope-auth.v2"; @@ -10,30 +11,48 @@ const HEADER_DOMAIN: &str = "typesec.did-envelope-auth.v2/header"; const SIGNATURE_DOMAIN: &str = "typesec.did-envelope-auth.v2/signature"; const REFERENCE_DOMAIN: &str = "typesec.did-envelope-auth.v2/reference"; +#[cfg(test)] #[derive(Clone, Copy)] pub(super) enum TranscriptKind { Header, Signature, - Reference, } /// Produce one length-framed transcript family for AEAD, signatures, and /// stable references. Signature and reference transcripts nest the exact bytes /// from the preceding stage so the authenticated header has one definition. +#[cfg(test)] pub(super) fn canonical_transcript(envelope: &DidEnvelope, kind: TranscriptKind) -> Vec { - let header = authenticated_header(envelope); + let mut transcript = Vec::new(); match kind { - TranscriptKind::Header => header, - TranscriptKind::Signature => signature_transcript(envelope, &header), - TranscriptKind::Reference => { - let signature = signature_transcript(envelope, &header); - reference_transcript(envelope, &signature) - } + TranscriptKind::Header => write_authenticated_header(envelope, &mut transcript), + TranscriptKind::Signature => write_signature_transcript(envelope, &mut transcript), } + transcript +} + +pub(super) fn authenticated_header(envelope: &DidEnvelope) -> Vec { + let mut header = Vec::new(); + write_authenticated_header(envelope, &mut header); + header +} + +pub(super) fn signature_transcript_from_header(envelope: &DidEnvelope, header: &[u8]) -> Vec { + let mut signature = Vec::new(); + let mut transcript = Transcript::new(&mut signature, SIGNATURE_DOMAIN); + transcript.bytes("authenticatedHeader", header); + transcript.string("ciphertext", &envelope.ciphertext); + signature } -fn authenticated_header(envelope: &DidEnvelope) -> Vec { - let mut transcript = Transcript::new(HEADER_DOMAIN); +pub(super) fn reference_sha256(envelope: &DidEnvelope) -> [u8; 32] { + let mut hasher = sha2::Sha256::new(); + write_reference_transcript(envelope, &mut hasher); + hasher.finalize().into() +} + +fn write_authenticated_header(envelope: &DidEnvelope, sink: &mut S) { + let mut transcript = Transcript::new(sink, HEADER_DOMAIN); transcript.string("authVersion", &envelope.auth_version); transcript.string("id", &envelope.id); transcript.string("messageType", &envelope.message_type); @@ -56,30 +75,39 @@ fn authenticated_header(envelope: &DidEnvelope) -> Vec { transcript.optional_conversation(envelope.typedid.as_ref()); transcript.string("kid", &envelope.kid); transcript.string("nonce", &envelope.nonce); - transcript.finish() } -fn signature_transcript(envelope: &DidEnvelope, header: &[u8]) -> Vec { - let mut transcript = Transcript::new(SIGNATURE_DOMAIN); - transcript.bytes("authenticatedHeader", header); +fn write_signature_transcript(envelope: &DidEnvelope, sink: &mut S) { + let header_bytes = encoded_len(|counter| write_authenticated_header(envelope, counter)); + let mut transcript = Transcript::new(sink, SIGNATURE_DOMAIN); + transcript.nested("authenticatedHeader", header_bytes, |sink| { + write_authenticated_header(envelope, sink); + }); transcript.string("ciphertext", &envelope.ciphertext); - transcript.finish() } -fn reference_transcript(envelope: &DidEnvelope, signature: &[u8]) -> Vec { - let mut transcript = Transcript::new(REFERENCE_DOMAIN); - transcript.bytes("signedEnvelope", signature); +fn write_reference_transcript(envelope: &DidEnvelope, sink: &mut S) { + let signature_bytes = encoded_len(|counter| write_signature_transcript(envelope, counter)); + let mut transcript = Transcript::new(sink, REFERENCE_DOMAIN); + transcript.nested("signedEnvelope", signature_bytes, |sink| { + write_signature_transcript(envelope, sink); + }); transcript.string("signature", &envelope.signature); - transcript.finish() } -struct Transcript { - bytes: Vec, +fn encoded_len(write: impl FnOnce(&mut ByteCount)) -> usize { + let mut counter = ByteCount::default(); + write(&mut counter); + counter.0 +} + +struct Transcript<'a, S> { + sink: &'a mut S, } -impl Transcript { - fn new(domain: &str) -> Self { - let mut transcript = Self { bytes: Vec::new() }; +impl<'a, S: TranscriptSink> Transcript<'a, S> { + fn new(sink: &'a mut S, domain: &str) -> Self { + let mut transcript = Self { sink }; transcript.string("domain", domain); transcript } @@ -89,8 +117,14 @@ impl Transcript { } fn bytes(&mut self, name: &str, value: &[u8]) { - frame(&mut self.bytes, name.as_bytes()); - frame(&mut self.bytes, value); + frame(self.sink, name.as_bytes()); + frame(self.sink, value); + } + + fn nested(&mut self, name: &str, value_len: usize, write: impl FnOnce(&mut S)) { + frame(self.sink, name.as_bytes()); + write_len(self.sink, value_len); + write(self.sink); } fn u64(&mut self, name: &str, value: u64) { @@ -126,10 +160,6 @@ impl Transcript { } } } - - fn finish(self) -> Vec { - self.bytes - } } fn mode_name(mode: TypeDidMode) -> &'static str { @@ -139,7 +169,36 @@ fn mode_name(mode: TypeDidMode) -> &'static str { } } -fn frame(output: &mut Vec, value: &[u8]) { - output.extend_from_slice(&(value.len() as u64).to_be_bytes()); - output.extend_from_slice(value); +trait TranscriptSink { + fn write(&mut self, bytes: &[u8]); +} + +impl TranscriptSink for Vec { + fn write(&mut self, bytes: &[u8]) { + self.extend_from_slice(bytes); + } +} + +impl TranscriptSink for sha2::Sha256 { + fn write(&mut self, bytes: &[u8]) { + self.update(bytes); + } +} + +#[derive(Default)] +struct ByteCount(usize); + +impl TranscriptSink for ByteCount { + fn write(&mut self, bytes: &[u8]) { + self.0 += bytes.len(); + } +} + +fn frame(output: &mut impl TranscriptSink, value: &[u8]) { + write_len(output, value.len()); + output.write(value); +} + +fn write_len(output: &mut impl TranscriptSink, value_len: usize) { + output.write(&(value_len as u64).to_be_bytes()); } diff --git a/crates/typesec-integrations/src/did/crypto.rs b/crates/typesec-integrations/src/did/crypto.rs index e1de088..1fe226c 100644 --- a/crates/typesec-integrations/src/did/crypto.rs +++ b/crates/typesec-integrations/src/did/crypto.rs @@ -11,12 +11,6 @@ pub(super) fn unix_time() -> u64 { .unwrap_or_default() } -/// SHA-256 digest of canonical protocol bytes. -pub(super) fn sha256(data: &[u8]) -> [u8; 32] { - use sha2::Digest; - sha2::Sha256::digest(data).into() -} - /// Domain-separated SHA-256: `SHA-256(domain || 0x00 || data)`. pub(super) fn sha256_tagged(domain: &[u8], data: &[u8]) -> [u8; 32] { use sha2::Digest; @@ -58,18 +52,49 @@ pub(super) fn hex_decode(value: &str) -> Result, DidError> { } let mut out = Vec::with_capacity(value.len() / 2); for chunk in value.as_bytes().chunks_exact(2) { - let high = hex_nibble(chunk[0])?; - let low = hex_nibble(chunk[1])?; + let high = HEX_VALUES[chunk[0] as usize]; + let low = HEX_VALUES[chunk[1] as usize]; + if high | low > 0x0f { + return Err(DidError::InvalidHex); + } out.push((high << 4) | low); } Ok(out) } -fn hex_nibble(byte: u8) -> Result { - match byte { - b'0'..=b'9' => Ok(byte - b'0'), - b'a'..=b'f' => Ok(byte - b'a' + 10), - b'A'..=b'F' => Ok(byte - b'A' + 10), - _ => Err(DidError::InvalidHex), +const HEX_VALUES: [u8; 256] = { + let mut values = [u8::MAX; 256]; + let mut digit = 0; + while digit < 10 { + values[b'0' as usize + digit] = digit as u8; + digit += 1; + } + let mut letter = 0; + while letter < 6 { + values[b'a' as usize + letter] = letter as u8 + 10; + values[b'A' as usize + letter] = letter as u8 + 10; + letter += 1; + } + values +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hexadecimal_round_trip_uses_canonical_lowercase() { + let bytes = [0x00, 0x01, 0x09, 0x0a, 0x10, 0xab, 0xcd, 0xef, 0xff]; + let encoded = hex_encode(&bytes); + assert_eq!(encoded, "0001090a10abcdefff"); + assert_eq!(hex_decode(&encoded).unwrap(), bytes); + assert_eq!(hex_decode("0001090A10AbCdEfFf").unwrap(), bytes); + } + + #[test] + fn hexadecimal_decode_rejects_odd_and_invalid_inputs() { + assert!(matches!(hex_decode("0"), Err(DidError::InvalidHex))); + assert!(matches!(hex_decode("0g"), Err(DidError::InvalidHex))); + assert!(matches!(hex_decode("💩"), Err(DidError::InvalidHex))); } } diff --git a/crates/typesec-integrations/src/did/envelope.rs b/crates/typesec-integrations/src/did/envelope.rs index 6264df2..49839ad 100644 --- a/crates/typesec-integrations/src/did/envelope.rs +++ b/crates/typesec-integrations/src/did/envelope.rs @@ -3,8 +3,10 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -use super::auth::{DID_ENVELOPE_AUTH_V2, TranscriptKind, canonical_transcript}; -use super::crypto::{hex_encode, random_nonce, sha256, unix_time}; +use super::auth::{ + DID_ENVELOPE_AUTH_V2, authenticated_header, reference_sha256, signature_transcript_from_header, +}; +use super::crypto::{hex_encode, random_nonce, unix_time}; use super::document::DidResolver; use super::error::DidError; use super::gateway::{VerifiedDidPrompt, VerifiedTypeDidMessage}; @@ -203,7 +205,8 @@ impl DidEnvelope { let aad = envelope.associated_data(); envelope.ciphertext = key_store.encrypt_for(&envelope.from, &recipient_public, plaintext, &nonce, &aad)?; - envelope.signature = key_store.sign(&envelope.from, &envelope.signing_input())?; + envelope.signature = + key_store.sign(&envelope.from, &envelope.signing_input_from_header(&aad))?; Ok(envelope) } @@ -322,10 +325,9 @@ impl DidEnvelope { /// Stable reference to this signed envelope for reply binding. pub fn reference(&self) -> DidMessageReference { - let transcript = canonical_transcript(self, TranscriptKind::Reference); DidMessageReference { id: self.id.clone(), - digest: format!("sha256:{}", hex_encode(&sha256(&transcript))), + digest: format!("sha256:{}", hex_encode(&reference_sha256(self))), } } @@ -334,15 +336,21 @@ impl DidEnvelope { /// The length-framed v2 header includes routing, timing, policy-visible /// body and claims, TypeDID conversation, reply binding, key id, and nonce. pub(super) fn associated_data(&self) -> Vec { - canonical_transcript(self, TranscriptKind::Header) + authenticated_header(self) } /// Canonical bytes the sender signs and the recipient verifies. /// /// The signature transcript nests the exact AEAD header and appends the /// ciphertext as one additional length-framed field. + #[cfg(test)] pub(super) fn signing_input(&self) -> Vec { - canonical_transcript(self, TranscriptKind::Signature) + let header = self.associated_data(); + self.signing_input_from_header(&header) + } + + pub(super) fn signing_input_from_header(&self, header: &[u8]) -> Vec { + signature_transcript_from_header(self, header) } pub(super) fn effective_expires_at(&self) -> u64 { diff --git a/crates/typesec-integrations/src/did/gateway.rs b/crates/typesec-integrations/src/did/gateway.rs index 494e1a1..b4b078d 100644 --- a/crates/typesec-integrations/src/did/gateway.rs +++ b/crates/typesec-integrations/src/did/gateway.rs @@ -339,8 +339,12 @@ impl DidMessageGateway { let sender_document = self.resolver.resolve(&envelope.from)?; let sender_key = sender_document.authentication_key(&envelope.kid)?; - self.key_store - .verify(sender_key, &envelope.signing_input(), &envelope.signature)?; + let aad = envelope.associated_data(); + self.key_store.verify( + sender_key, + &envelope.signing_input_from_header(&aad), + &envelope.signature, + )?; // Semantic routing is meaningful only after `message_type` has been // authenticated. Reject cross-protocol envelopes before key agreement, // decryption, or replay-store consumption. @@ -353,7 +357,6 @@ impl DidMessageGateway { // sender document. let sender_agreement_keys = sender_document.key_agreement_keys()?; let nonce = hex_decode(&envelope.nonce)?; - let aad = envelope.associated_data(); let mut plaintext = None; for sender_agreement_key in sender_agreement_keys { match self.key_store.decrypt_for( diff --git a/crates/typesec-integrations/src/did/replay.rs b/crates/typesec-integrations/src/did/replay.rs index 18e36ca..f00b5c4 100644 --- a/crates/typesec-integrations/src/did/replay.rs +++ b/crates/typesec-integrations/src/did/replay.rs @@ -1,7 +1,8 @@ //! Replay-claim authority for DID and TypeDID gateways. -use std::collections::HashMap; -use std::sync::{Mutex, PoisonError}; +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashMap}; +use std::sync::{Arc, Mutex, PoisonError}; /// Atomically records authenticated envelope identities until they expire. /// @@ -18,7 +19,31 @@ pub trait ReplayStore: Send + Sync { /// Process-local replay authority used by default. #[derive(Debug, Default)] pub struct InMemoryReplayStore { - seen: Mutex>, + claims: Mutex, +} + +#[derive(Debug, Default)] +struct ReplayClaims { + seen: HashMap, u64>, + expirations: BinaryHeap)>>, +} + +impl ReplayClaims { + fn remove_expired(&mut self, now: u64) { + while self + .expirations + .peek() + .is_some_and(|Reverse((expiry, _))| *expiry < now) + { + let Reverse((expiry, key)) = self + .expirations + .pop() + .expect("peeked replay expiration must remain present"); + if self.seen.get(key.as_ref()) == Some(&expiry) { + self.seen.remove(key.as_ref()); + } + } + } } impl InMemoryReplayStore { @@ -30,12 +55,45 @@ impl InMemoryReplayStore { impl ReplayStore for InMemoryReplayStore { fn claim(&self, key: &str, expires_at: u64, now: u64) -> Result { - let mut seen = self.seen.lock().unwrap_or_else(PoisonError::into_inner); - seen.retain(|_, expiry| *expiry >= now); - if seen.contains_key(key) { + let mut claims = self.claims.lock().unwrap_or_else(PoisonError::into_inner); + claims.remove_expired(now); + if claims.seen.contains_key(key) { return Ok(false); } - seen.insert(key.to_owned(), expires_at); + let key: Arc = Arc::from(key); + claims.seen.insert(Arc::clone(&key), expires_at); + claims.expirations.push(Reverse((expires_at, key))); Ok(true) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn active_claims_are_rejected_through_the_expiry_second() { + let store = InMemoryReplayStore::new(); + assert!(store.claim("envelope", 20, 10).unwrap()); + assert!(!store.claim("envelope", 20, 19).unwrap()); + assert!(!store.claim("envelope", 20, 20).unwrap()); + } + + #[test] + fn expired_claims_can_be_reclaimed() { + let store = InMemoryReplayStore::new(); + assert!(store.claim("envelope", 20, 10).unwrap()); + assert!(store.claim("envelope", 40, 21).unwrap()); + assert!(!store.claim("envelope", 40, 21).unwrap()); + } + + #[test] + fn expiration_pruning_preserves_later_active_claims() { + let store = InMemoryReplayStore::new(); + assert!(store.claim("early", 20, 10).unwrap()); + assert!(store.claim("late", 40, 10).unwrap()); + assert!(store.claim("new", 50, 21).unwrap()); + assert!(!store.claim("late", 40, 21).unwrap()); + assert!(store.claim("early", 50, 21).unwrap()); + } +} diff --git a/crates/typesec-integrations/src/lib.rs b/crates/typesec-integrations/src/lib.rs index dba1873..b0fe801 100644 --- a/crates/typesec-integrations/src/lib.rs +++ b/crates/typesec-integrations/src/lib.rs @@ -38,6 +38,6 @@ pub use otel::OtelAuditSink; pub use pydantic_ai::{PydanticAiCapability, PydanticAiToolCapability}; pub use receipt::{ CognitionCommitReceipt, CognitionCommitReceiptClaims, CognitionEffect, DecisionReceipt, - ReceiptError, ReceiptIssuer, ReceiptVerifier, + ReceiptError, ReceiptIssuer, ReceiptVerifier, SemanticDecisionAction, SemanticDecisionReceipt, }; pub use workos::{WorkOsFgaEngine, WorkOsFgaRequest, WorkOsResource}; diff --git a/crates/typesec-integrations/src/receipt.rs b/crates/typesec-integrations/src/receipt.rs index 283fb81..5a36748 100644 --- a/crates/typesec-integrations/src/receipt.rs +++ b/crates/typesec-integrations/src/receipt.rs @@ -23,7 +23,9 @@ use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; mod cognition; +mod semantic; pub use cognition::{CognitionCommitReceipt, CognitionCommitReceiptClaims, CognitionEffect}; +pub use semantic::{SemanticDecisionAction, SemanticDecisionReceipt}; /// The signed claims: one allowed decision, bounded in time. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -140,20 +142,28 @@ impl ReceiptIssuer { receipt: &CognitionCommitReceipt, now: DateTime, ) -> Result { - receipt.validate()?; validate_window(receipt.issued_at(), receipt.expires_at(), now)?; Ok(self.issue_claims(receipt)) } + /// Sign an allowed, model-version-bound semantic decision. + pub fn issue_semantic(&self, receipt: &SemanticDecisionReceipt) -> String { + self.issue_claims(receipt) + } + fn issue_claims(&self, receipt: &impl Serialize) -> String { let claims = serde_json::to_vec(receipt) .expect("receipt serialization cannot fail: all fields are JSON-safe"); let signature = self.key.sign(&claims); - format!( - "{}.{}", - B64.encode(&claims), - B64.encode(signature.to_bytes()) - ) + let claims_len = base64::encoded_len(claims.len(), false) + .expect("serialized receipt length must fit in memory"); + let signature_len = base64::encoded_len(Signature::BYTE_SIZE, false) + .expect("fixed-size signature length cannot overflow"); + let mut token = String::with_capacity(claims_len + 1 + signature_len); + B64.encode_string(&claims, &mut token); + token.push('.'); + B64.encode_string(signature.to_bytes(), &mut token); + token } } @@ -185,11 +195,22 @@ impl ReceiptVerifier { now: DateTime, ) -> Result { let receipt: CognitionCommitReceipt = self.verify_claims(token)?; - receipt.validate()?; validate_window(receipt.issued_at(), receipt.expires_at(), now)?; Ok(receipt) } + /// Verify a model-version-bound semantic decision receipt. + pub fn verify_semantic( + &self, + token: &str, + now: DateTime, + ) -> Result { + let receipt: SemanticDecisionReceipt = self.verify_claims(token)?; + receipt.validate()?; + validate_window(receipt.issued_at, receipt.expires_at, now)?; + Ok(receipt) + } + fn verify_claims(&self, token: &str) -> Result { let (claims_b64, signature_b64) = token .split_once('.') @@ -197,11 +218,16 @@ impl ReceiptVerifier { let claims = B64 .decode(claims_b64) .map_err(|err| ReceiptError::Malformed(format!("claims are not base64url: {err}")))?; - let signature_bytes: [u8; 64] = B64 - .decode(signature_b64) - .map_err(|err| ReceiptError::Malformed(format!("signature is not base64url: {err}")))? - .try_into() - .map_err(|_| ReceiptError::Malformed("signature is not 64 bytes".into()))?; + if signature_b64.len() != base64::encoded_len(Signature::BYTE_SIZE, false).unwrap() { + return Err(ReceiptError::Malformed("signature is not 64 bytes".into())); + } + let mut signature_bytes = [0_u8; Signature::BYTE_SIZE]; + let decoded_signature_len = B64 + .decode_slice(signature_b64, &mut signature_bytes) + .map_err(|err| ReceiptError::Malformed(format!("signature is not base64url: {err}")))?; + if decoded_signature_len != signature_bytes.len() { + return Err(ReceiptError::Malformed("signature is not 64 bytes".into())); + } self.key .verify(&claims, &Signature::from_bytes(&signature_bytes)) .map_err(|_| ReceiptError::BadSignature)?; diff --git a/crates/typesec-integrations/src/receipt/semantic.rs b/crates/typesec-integrations/src/receipt/semantic.rs new file mode 100644 index 0000000..e0e78a4 --- /dev/null +++ b/crates/typesec-integrations/src/receipt/semantic.rs @@ -0,0 +1,125 @@ +use chrono::{DateTime, TimeDelta, Utc}; +use serde::{Deserialize, Serialize}; + +use super::ReceiptError; + +/// Closed decision vocabulary for governed semantic-model operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SemanticDecisionAction { + /// Publish a new immutable model version. + PublishModel, + /// Consume a published model version. + ConsumeModel, + /// Read one governed semantic field. + AccessField, + /// Execute one governed metric definition. + ExecuteMetric, + /// Execute a composed semantic query. + ExecuteSemanticQuery, + /// Expose model AI context to an agent or model. + AccessAiContext, +} + +/// Positive-only signed claims bound to one immutable semantic model version. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SemanticDecisionReceipt { + /// Authorized principal. + pub subject: String, + /// Exact allowed operation. + pub action: SemanticDecisionAction, + /// Dataset, field, metric, query, or AI-context resource. + pub resource: String, + /// Stable semantic model identity. + pub model_id: String, + /// Positive immutable publication version. + pub model_version: u64, + /// Hash of the exact model artifact. + pub artifact_hash: String, + /// Hash of the policy input used for the decision. + pub policy_hash: String, + /// Optional hash of the validated physical binding set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub physical_binding_hash: Option, + /// Receipt issuance time. + pub issued_at: DateTime, + /// Exclusive receipt expiry. + pub expires_at: DateTime, +} + +impl SemanticDecisionReceipt { + /// Construct validated positive claims for one immutable model version. + #[allow(clippy::too_many_arguments)] + pub fn new( + subject: impl Into, + action: SemanticDecisionAction, + resource: impl Into, + model_id: impl Into, + model_version: u64, + artifact_hash: impl Into, + policy_hash: impl Into, + physical_binding_hash: Option, + now: DateTime, + ttl: TimeDelta, + ) -> Result { + let receipt = Self { + subject: subject.into(), + action, + resource: resource.into(), + model_id: model_id.into(), + model_version, + artifact_hash: artifact_hash.into(), + policy_hash: policy_hash.into(), + physical_binding_hash, + issued_at: now, + expires_at: now + ttl, + }; + receipt.validate()?; + Ok(receipt) + } + + pub(crate) fn validate(&self) -> Result<(), ReceiptError> { + for (name, value) in [ + ("subject", self.subject.as_str()), + ("resource", self.resource.as_str()), + ("model id", self.model_id.as_str()), + ] { + if value.trim().is_empty() { + return Err(ReceiptError::InvalidClaims(format!( + "{name} must not be empty" + ))); + } + } + if self.model_version == 0 { + return Err(ReceiptError::InvalidClaims( + "model version must be positive".into(), + )); + } + for (name, value) in [ + ("artifact hash", Some(self.artifact_hash.as_str())), + ("policy hash", Some(self.policy_hash.as_str())), + ( + "physical binding hash", + self.physical_binding_hash.as_deref(), + ), + ] { + if let Some(value) = value { + let valid = value.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + }); + if !valid { + return Err(ReceiptError::InvalidClaims(format!( + "{name} must be sha256" + ))); + } + } + } + if self.expires_at <= self.issued_at { + return Err(ReceiptError::InvalidClaims( + "semantic receipt expiry must follow issuance".into(), + )); + } + Ok(()) + } +} diff --git a/crates/typesec-integrations/src/receipt/tests.rs b/crates/typesec-integrations/src/receipt/tests.rs index deeba2a..1a019a4 100644 --- a/crates/typesec-integrations/src/receipt/tests.rs +++ b/crates/typesec-integrations/src/receipt/tests.rs @@ -1,4 +1,77 @@ use super::*; + +#[test] +fn every_semantic_action_round_trips_as_signed_model_bound_claims() { + let now = Utc::now(); + let issuer = ReceiptIssuer::new(ed25519_dalek::SigningKey::from_bytes(&[9; 32])); + let verifier = ReceiptVerifier::new(issuer.verifying_key()); + for action in [ + SemanticDecisionAction::PublishModel, + SemanticDecisionAction::ConsumeModel, + SemanticDecisionAction::AccessField, + SemanticDecisionAction::ExecuteMetric, + SemanticDecisionAction::ExecuteSemanticQuery, + SemanticDecisionAction::AccessAiContext, + ] { + let receipt = SemanticDecisionReceipt::new( + "did:key:publisher", + action, + "semantic://tpcds/store_sales", + "tpcds", + 1, + format!("sha256:{}", "1".repeat(64)), + format!("sha256:{}", "2".repeat(64)), + Some(format!("sha256:{}", "3".repeat(64))), + now, + TimeDelta::minutes(5), + ) + .unwrap(); + let token = issuer.issue_semantic(&receipt); + assert_eq!(verifier.verify_semantic(&token, now).unwrap(), receipt); + } +} + +#[test] +fn semantic_receipt_rejects_unbound_or_tampered_claims() { + let now = Utc::now(); + assert!( + SemanticDecisionReceipt::new( + "agent", + SemanticDecisionAction::ConsumeModel, + "semantic://m", + "m", + 0, + format!("sha256:{}", "1".repeat(64)), + format!("sha256:{}", "2".repeat(64)), + None, + now, + TimeDelta::minutes(1), + ) + .is_err() + ); + + let issuer = ReceiptIssuer::new(ed25519_dalek::SigningKey::from_bytes(&[8; 32])); + let verifier = ReceiptVerifier::new(issuer.verifying_key()); + let receipt = SemanticDecisionReceipt::new( + "agent", + SemanticDecisionAction::AccessField, + "semantic://m/f", + "m", + 1, + format!("sha256:{}", "1".repeat(64)), + format!("sha256:{}", "2".repeat(64)), + None, + now, + TimeDelta::minutes(1), + ) + .unwrap(); + let mut token = issuer.issue_semantic(&receipt).into_bytes(); + token[5] = if token[5] == b'A' { b'B' } else { b'A' }; + assert!(matches!( + verifier.verify_semantic(std::str::from_utf8(&token).unwrap(), now), + Err(ReceiptError::BadSignature) + )); +} use chrono::TimeZone; fn now() -> DateTime { diff --git a/crates/typesec-memory/Cargo.toml b/crates/typesec-memory/Cargo.toml index efe334c..67478db 100644 --- a/crates/typesec-memory/Cargo.toml +++ b/crates/typesec-memory/Cargo.toml @@ -43,9 +43,18 @@ thiserror = { workspace = true } tracing = { workspace = true } [dev-dependencies] +criterion = { workspace = true } typesec-rbac = { version = "0.13.1", path = "../typesec-rbac" } typesec-odrl = { version = "0.13.1", path = "../typesec-odrl" } typesec-integrations = { version = "0.13.1", path = "../typesec-integrations" } ed25519-dalek = { workspace = true } chrono = { workspace = true } trybuild = "1" + +[[bench]] +name = "memory_paths" +harness = false + +[[bench]] +name = "cognition_paths" +harness = false diff --git a/crates/typesec-memory/benches/cognition_paths.rs b/crates/typesec-memory/benches/cognition_paths.rs new file mode 100644 index 0000000..9d0f10a --- /dev/null +++ b/crates/typesec-memory/benches/cognition_paths.rs @@ -0,0 +1,149 @@ +use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; +use typesec_memory::{ + CognitionBinding, CognitionProposal, Label, MemoryContent, MemoryDraft, MemoryId, MemoryKind, + Provenance, governed_source_draft_digest, +}; + +const PROPOSAL_CASES: [(usize, usize); 3] = [(1, 256), (64, 1_024), (256, 1_024)]; + +fn digest(fill: char) -> String { + format!("sha256:{}", fill.to_string().repeat(64)) +} + +fn binding(projection_count: usize) -> CognitionBinding { + CognitionBinding { + space_id: "memory/agent:bench/profile".to_owned(), + subject: "did:key:benchmark".to_owned(), + purpose: "benchmark".to_owned(), + governed_source_scope: None, + governed_scan_digest: digest('1'), + snapshot_digest: digest('2'), + plan_task_digest: digest('3'), + authorization_receipt_digest: digest('4'), + effective_projection: (0..projection_count) + .rev() + .map(|index| format!("field-{index:04}")) + .collect(), + source_manifest_digest: digest('5'), + typedid_request_digest: digest('6'), + } +} + +fn draft(text_bytes: usize) -> MemoryDraft { + MemoryDraft::new( + MemoryKind::Semantic, + MemoryContent::text("x".repeat(text_bytes)), + Provenance::Operator, + ) + .with_label(Label::Internal) +} + +fn proposal(draft_count: usize, text_bytes: usize) -> CognitionProposal { + let binding = binding(16); + CognitionProposal::new( + "benchmark-job", + binding.snapshot_digest.clone(), + binding.source_manifest_digest.clone(), + "marciana.benchmark", + "1", + vec![MemoryId::from_string("benchmark-source")], + Label::Internal, + ) + .with_drafts((0..draft_count).map(|_| draft(text_bytes)).collect()) + .with_binding(binding) +} + +fn bench_governed_draft_digest(c: &mut Criterion) { + let mut group = c.benchmark_group("governed_draft_digest"); + group.sample_size(30); + for text_bytes in [256, 65_536] { + let draft = draft(text_bytes); + let serialized_bytes = serde_json::to_vec(&draft) + .expect("serialize benchmark draft") + .len(); + group.throughput(Throughput::Bytes(serialized_bytes as u64)); + group.bench_with_input( + BenchmarkId::from_parameter(format_args!("{text_bytes}_text_bytes")), + &draft, + |b, draft| { + b.iter(|| { + black_box(governed_source_draft_digest(black_box(draft)).expect("draft digest")) + }) + }, + ); + } + group.finish(); +} + +fn bench_binding_digest(c: &mut Criterion) { + let mut group = c.benchmark_group("cognition_binding_digest"); + group.sample_size(30); + for projection_count in [4, 256] { + let binding = binding(projection_count); + let serialized_bytes = serde_json::to_vec(&binding) + .expect("serialize benchmark binding") + .len(); + group.throughput(Throughput::Bytes(serialized_bytes as u64)); + group.bench_with_input( + BenchmarkId::from_parameter(format_args!("{projection_count}_fields")), + &binding, + |b, binding| b.iter(|| black_box(binding.canonical_digest().expect("binding digest"))), + ); + } + group.finish(); +} + +fn bench_proposal_digest(c: &mut Criterion) { + let mut group = c.benchmark_group("cognition_proposal_digest"); + group.sample_size(30); + for (draft_count, text_bytes) in PROPOSAL_CASES { + let proposal = proposal(draft_count, text_bytes); + let serialized_bytes = serde_json::to_vec(&proposal) + .expect("serialize benchmark proposal") + .len(); + proposal + .canonical_digest() + .expect("valid benchmark proposal"); + group.throughput(Throughput::Bytes(serialized_bytes as u64)); + group.bench_with_input( + BenchmarkId::new("drafts", draft_count), + &proposal, + |b, proposal| { + b.iter(|| black_box(proposal.canonical_digest().expect("proposal digest"))) + }, + ); + } + group.finish(); +} + +fn bench_proposal_wire(c: &mut Criterion) { + let mut group = c.benchmark_group("cognition_proposal_wire"); + group.sample_size(30); + for (draft_count, text_bytes) in PROPOSAL_CASES { + let proposal = proposal(draft_count, text_bytes); + let serialized_bytes = serde_json::to_vec(&proposal) + .expect("serialize benchmark proposal") + .len(); + group.throughput(Throughput::Bytes(serialized_bytes as u64)); + group.bench_with_input( + BenchmarkId::new("serialize_to_sink", draft_count), + &proposal, + |b, proposal| { + b.iter(|| { + serde_json::to_writer(std::io::sink(), black_box(proposal)) + .expect("serialize proposal") + }) + }, + ); + } + group.finish(); +} + +criterion_group!( + benches, + bench_governed_draft_digest, + bench_binding_digest, + bench_proposal_digest, + bench_proposal_wire +); +criterion_main!(benches); diff --git a/crates/typesec-memory/benches/memory_paths.rs b/crates/typesec-memory/benches/memory_paths.rs new file mode 100644 index 0000000..8488e3b --- /dev/null +++ b/crates/typesec-memory/benches/memory_paths.rs @@ -0,0 +1,166 @@ +use criterion::{Criterion, Throughput, black_box, criterion_group, criterion_main}; +use typesec_memory::{ + InMemoryStore, KeywordIndex, Label, MemoryId, MemoryStore, SemanticIndex, StoreQuery, + StoredRecord, +}; + +const RECORD_COUNT: usize = 10_000; + +fn record(index: usize) -> StoredRecord { + let text = if index.is_multiple_of(10) { + format!("record {index} contains the target phrase") + } else { + format!("ordinary memory record {index}") + }; + serde_json::from_value(serde_json::json!({ + "id": format!("bench-{index:05}"), + "space_id": "memory/agent:bench/profile", + "kind": "semantic", + "label": "internal", + "quarantined": false, + "entities": [], + "provenance": { "source": "operator" }, + "observed_at": "2026-08-07T00:00:00Z", + "valid_from": "2026-01-01T00:00:00Z", + "invalid_at": null, + "expires_at": null, + "purposes": [], + "content": { "text": text } + })) + .expect("valid benchmark record") +} + +fn populated_store() -> InMemoryStore { + let store = InMemoryStore::new(); + for index in 0..RECORD_COUNT { + store.put(record(index)).expect("insert benchmark record"); + } + store +} + +fn populated_keyword_index() -> KeywordIndex { + let index = KeywordIndex::new(); + for record_index in 0..RECORD_COUNT { + let (id, text) = keyword_document(record_index); + index + .index(&id, typesec_memory::Label::Internal, &text) + .expect("index benchmark record"); + } + index +} + +fn keyword_document(index: usize) -> (MemoryId, String) { + let text = if index.is_multiple_of(10) { + format!("record {index} contains the target phrase") + } else { + format!("ordinary memory record {index}") + }; + (MemoryId::from_string(format!("bench-{index:05}")), text) +} + +fn bench_store_queries(c: &mut Criterion) { + let store = populated_store(); + let latest = StoreQuery { + space_id: Some("memory/agent:bench/profile".to_owned()), + limit: Some(10), + ..StoreQuery::default() + }; + let text = StoreQuery { + space_id: Some("memory/agent:bench/profile".to_owned()), + text_contains: Some("TARGET PHRASE".to_owned()), + limit: Some(10), + ..StoreQuery::default() + }; + + let mut group = c.benchmark_group("memory_store_query_10k"); + group.sample_size(30); + group.throughput(Throughput::Elements(RECORD_COUNT as u64)); + group.bench_function("latest_limit_10", |b| { + b.iter(|| black_box(store.query(black_box(&latest)).expect("query"))) + }); + group.bench_function("case_insensitive_text_limit_10", |b| { + b.iter(|| black_box(store.query(black_box(&text)).expect("query"))) + }); + group.finish(); +} + +fn bench_keyword_search(c: &mut Criterion) { + let index = populated_keyword_index(); + let mut group = c.benchmark_group("keyword_index_search_10k"); + group.sample_size(30); + group.throughput(Throughput::Elements(RECORD_COUNT as u64)); + for (name, query) in [ + ("sparse_target_phrase_limit_10", "target phrase"), + ("common_memory_record_limit_10", "memory record"), + ("missing_tokens_limit_10", "tokens absent everywhere"), + ] { + group.bench_function(name, |b| { + b.iter(|| { + black_box( + index + .search(black_box(query), black_box(10)) + .expect("search"), + ) + }) + }); + } + group.finish(); + + let id = MemoryId::from_string("bench-00000"); + let mut group = c.benchmark_group("keyword_index_mutation_10k"); + group.sample_size(30); + group.throughput(Throughput::Elements(1)); + group.bench_function("reindex_unchanged", |b| { + b.iter(|| { + index + .index( + black_box(&id), + black_box(Label::Internal), + black_box("updated target phrase memory record"), + ) + .expect("replace indexed record") + }) + }); + group.bench_function("replace_changed_tokens", |b| { + let mut use_alpha = false; + b.iter(|| { + use_alpha = !use_alpha; + let text = if use_alpha { + "alternating alpha memory record" + } else { + "alternating beta memory record" + }; + index + .index(black_box(&id), black_box(Label::Internal), black_box(text)) + .expect("replace indexed record") + }) + }); + group.finish(); +} + +fn bench_keyword_population(c: &mut Criterion) { + let documents = (0..RECORD_COUNT).map(keyword_document).collect::>(); + let mut group = c.benchmark_group("keyword_index_population"); + group.sample_size(20); + group.throughput(Throughput::Elements(RECORD_COUNT as u64)); + group.bench_function("build_10k", |b| { + b.iter_with_large_drop(|| { + let index = KeywordIndex::new(); + for (id, text) in &documents { + index + .index(id, Label::Internal, text) + .expect("index benchmark record"); + } + index + }) + }); + group.finish(); +} + +criterion_group!( + benches, + bench_store_queries, + bench_keyword_search, + bench_keyword_population +); +criterion_main!(benches); diff --git a/crates/typesec-memory/src/cognition.rs b/crates/typesec-memory/src/cognition.rs index e06101d..9bb51ad 100644 --- a/crates/typesec-memory/src/cognition.rs +++ b/crates/typesec-memory/src/cognition.rs @@ -54,7 +54,7 @@ impl crate::CognitionProposal { proposal.schema_version, )); } - validate::validate_proposal_shape(&proposal)?; + validate::validate_decoded_proposal(&proposal)?; if let Some(binding) = &proposal.binding { binding.validate()?; } @@ -67,19 +67,19 @@ impl crate::CognitionProposal { /// is excluded, so a later worker retry of the same governed decision has /// the same identity without reimplementing TypeSec internals. pub fn canonical_digest(&self) -> Result { - validate::validate_proposal_shape(self)?; + let digest = validate::validate_proposal_identity(self)?; if let Some(binding) = &self.binding { binding.validate()?; } - digest::proposal_digest(self) + Ok(digest) } } impl CognitionBinding { /// Domain-separated canonical digest of governed authority evidence. pub fn canonical_digest(&self) -> Result { - self.validate()?; - digest::binding_digest(self) + let projection = self.validate_and_canonical_projection()?; + digest::binding_digest_with_projection(self, projection) } } diff --git a/crates/typesec-memory/src/cognition/apply.rs b/crates/typesec-memory/src/cognition/apply.rs index eb56916..e56a6cb 100644 --- a/crates/typesec-memory/src/cognition/apply.rs +++ b/crates/typesec-memory/src/cognition/apply.rs @@ -105,7 +105,7 @@ impl MemoryVault { let verifier = self .cognition_authority() .ok_or(CognitionApplyError::AuthorityVerifierUnavailable)?; - validate_proposal_for_application(proposal)?; + let proposal_digest = validate_proposal_for_application(proposal)?; let binding = proposal .binding .as_ref() @@ -121,7 +121,8 @@ impl MemoryVault { validate_authority(proposal, binding, &authority)?; let authority_revalidated_at = clock(); - let identity = CognitionCommitIdentity::from_validated(space, proposal, binding)?; + let identity = + CognitionCommitIdentity::from_validated(space, proposal, binding, proposal_digest)?; if let Some(recovered) = self .store() .recover_cognition(&identity.key, &identity.proposal_digest)? diff --git a/crates/typesec-memory/src/cognition/canonical.rs b/crates/typesec-memory/src/cognition/canonical.rs index dd0e1eb..760af20 100644 --- a/crates/typesec-memory/src/cognition/canonical.rs +++ b/crates/typesec-memory/src/cognition/canonical.rs @@ -3,6 +3,12 @@ use super::limits::MAX_COGNITION_IDENTITY_BYTES; pub(super) use crate::canonical::is_canonical_sha256; +pub(super) fn canonical_projection(projection: &[String]) -> Vec<&str> { + let mut canonical = projection.iter().map(String::as_str).collect::>(); + canonical.sort_unstable(); + canonical +} + pub(super) fn is_canonical_text(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_COGNITION_IDENTITY_BYTES diff --git a/crates/typesec-memory/src/cognition/digest.rs b/crates/typesec-memory/src/cognition/digest.rs index dc00c70..a94b07e 100644 --- a/crates/typesec-memory/src/cognition/digest.rs +++ b/crates/typesec-memory/src/cognition/digest.rs @@ -1,14 +1,16 @@ use serde::Serialize; use sha2::{Digest, Sha256}; -use super::PreparedCognitionCommit; +use super::canonical::canonical_projection; use super::types::{ CognitionApplyError, CognitionBinding, CognitionSourceManifest, CognitionSourcePrecondition, }; +use super::{CognitionEffect, PreparedCognitionCommit}; use crate::index::IndexMutation; use crate::store::StoreBatchOp; use crate::{ - CognitionAuditEvidence, CognitionIdempotencyKey, CognitionProposal, Label, StoredRecord, + CognitionAuditEvidence, CognitionIdempotencyKey, CognitionProposal, ConsolidationPlan, + GovernedSourceScope, Label, MemoryDraft, MemoryId, StoredRecord, }; const RECORD_DOMAIN: &[u8] = b"typesec.marciana.source-record.v1\0"; @@ -18,6 +20,8 @@ const PROPOSAL_DOMAIN: &[u8] = b"typesec.marciana.proposal.v2\0"; const EVIDENCE_DOMAIN: &[u8] = b"typesec.marciana.evidence.v1\0"; const PREPARED_COMMIT_DOMAIN: &[u8] = b"typesec.marciana.prepared-commit.v5\0"; const AUTHORITY_SCOPE_DOMAIN: &[u8] = b"typesec.marciana.authority-scope.v1\0"; +// Coalesce serde's field-sized writes before the accelerated SHA backend. +const PROPOSAL_DIGEST_BUFFER_BYTES: usize = 32 * 1024; fn tagged_serialized_digest( domain: &[u8], @@ -71,22 +75,186 @@ pub(super) fn source_manifest( } pub(super) fn binding_digest(binding: &CognitionBinding) -> Result { - let mut canonical = binding.clone(); - canonical.effective_projection.sort(); - tagged_serialized_digest(BINDING_DOMAIN, &canonical) -} - -pub(super) fn proposal_digest(proposal: &CognitionProposal) -> Result { - let mut canonical = proposal.clone(); - // Creation time is observational scheduler metadata, not mutation - // identity. A worker retry may regenerate the same inert proposal later; - // every authority, source, plan, draft, and evidence field remains bound - // below while the durable idempotency digest stays stable. - canonical.created_at = chrono::DateTime::::UNIX_EPOCH; - if let Some(binding) = &mut canonical.binding { - binding.effective_projection.sort(); + binding_digest_with_projection(binding, canonical_projection(&binding.effective_projection)) +} + +pub(super) fn binding_digest_with_projection( + binding: &CognitionBinding, + projection: Vec<&str>, +) -> Result { + tagged_serialized_digest(BINDING_DOMAIN, &CanonicalBinding::new(binding, projection)) +} + +pub(super) fn proposal_digest_with_wire_limit( + proposal: &CognitionProposal, + max_wire_bytes: usize, +) -> Result { + // Canonicalization only reorders projection strings (which preserves byte + // length) and normalizes this timestamp. Adjusting for the two serialized + // timestamp lengths therefore enforces the exact noncanonical wire limit + // while hashing the canonical representation in the same bounded pass. + let canonical_time_bytes = serialized_len(&chrono::DateTime::::UNIX_EPOCH)?; + let observed_time_bytes = serialized_len(&proposal.created_at)?; + let canonical_limit = max_wire_bytes + .checked_sub(observed_time_bytes) + .and_then(|remaining| remaining.checked_add(canonical_time_bytes)) + .ok_or(CognitionApplyError::LimitExceeded("proposal bytes"))?; + + let mut digest = Sha256::new(); + digest.update(PROPOSAL_DOMAIN); + let mut writer = BoundedDigestWriter::new(&mut digest, canonical_limit); + let serialized = { + let mut buffered = + std::io::BufWriter::with_capacity(PROPOSAL_DIGEST_BUFFER_BYTES, &mut writer); + serde_json::to_writer(&mut buffered, &CanonicalProposal::new(proposal)) + .and_then(|()| std::io::Write::flush(&mut buffered).map_err(serde_json::Error::io)) + }; + if writer.exceeded { + return Err(CognitionApplyError::LimitExceeded("proposal bytes")); + } + serialized.map_err(|error| CognitionApplyError::Serialization(error.to_string()))?; + Ok(format!("sha256:{:x}", digest.finalize())) +} + +fn serialized_len(value: &impl Serialize) -> Result { + let mut writer = LengthWriter::default(); + serde_json::to_writer(&mut writer, value) + .map_err(|error| CognitionApplyError::Serialization(error.to_string()))?; + Ok(writer.written) +} + +#[derive(Default)] +struct LengthWriter { + written: usize, +} + +impl std::io::Write for LengthWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.written = self + .written + .checked_add(bytes.len()) + .ok_or_else(|| std::io::Error::other("serialized length overflow"))?; + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +struct BoundedDigestWriter<'a> { + digest: &'a mut Sha256, + remaining: usize, + exceeded: bool, +} + +impl<'a> BoundedDigestWriter<'a> { + fn new(digest: &'a mut Sha256, limit: usize) -> Self { + Self { + digest, + remaining: limit, + exceeded: false, + } + } +} + +impl std::io::Write for BoundedDigestWriter<'_> { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if bytes.len() > self.remaining { + self.exceeded = true; + return Err(std::io::Error::other( + "cognition proposal exceeds byte limit", + )); + } + self.remaining -= bytes.len(); + self.digest.update(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CanonicalBinding<'a> { + space_id: &'a str, + subject: &'a str, + purpose: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + governed_source_scope: Option<&'a GovernedSourceScope>, + governed_scan_digest: &'a str, + snapshot_digest: &'a str, + plan_task_digest: &'a str, + authorization_receipt_digest: &'a str, + effective_projection: Vec<&'a str>, + source_manifest_digest: &'a str, + typedid_request_digest: &'a str, +} + +impl<'a> CanonicalBinding<'a> { + fn new(binding: &'a CognitionBinding, effective_projection: Vec<&'a str>) -> Self { + Self { + space_id: &binding.space_id, + subject: &binding.subject, + purpose: &binding.purpose, + governed_source_scope: binding.governed_source_scope.as_ref(), + governed_scan_digest: &binding.governed_scan_digest, + snapshot_digest: &binding.snapshot_digest, + plan_task_digest: &binding.plan_task_digest, + authorization_receipt_digest: &binding.authorization_receipt_digest, + effective_projection, + source_manifest_digest: &binding.source_manifest_digest, + typedid_request_digest: &binding.typedid_request_digest, + } + } +} + +#[derive(Serialize)] +struct CanonicalProposal<'a> { + schema_version: u32, + effect: CognitionEffect, + job_id: &'a str, + input_snapshot: &'a str, + source_digest: &'a str, + algorithm: &'a str, + algorithm_version: &'a str, + source_ids: &'a [MemoryId], + joined_label: Label, + drafts: &'a [MemoryDraft], + plan: &'a ConsolidationPlan, + evidence: &'a [String], + created_at: chrono::DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + binding: Option>, +} + +impl<'a> CanonicalProposal<'a> { + fn new(proposal: &'a CognitionProposal) -> Self { + Self { + schema_version: proposal.schema_version, + effect: proposal.effect, + job_id: &proposal.job_id, + input_snapshot: &proposal.input_snapshot, + source_digest: &proposal.source_digest, + algorithm: &proposal.algorithm, + algorithm_version: &proposal.algorithm_version, + source_ids: &proposal.source_ids, + joined_label: proposal.joined_label, + drafts: &proposal.drafts, + plan: &proposal.plan, + evidence: &proposal.evidence, + // Creation time is observational scheduler metadata, not mutation + // identity. A worker retry may regenerate the same inert proposal + // later while every authority, source, plan, draft, and evidence + // field remains bound. + created_at: chrono::DateTime::::UNIX_EPOCH, + binding: proposal.binding.as_ref().map(|binding| { + CanonicalBinding::new(binding, canonical_projection(&binding.effective_projection)) + }), + } } - tagged_serialized_digest(PROPOSAL_DOMAIN, &canonical) } pub(super) fn evidence_digest(evidence: &[String]) -> Result { diff --git a/crates/typesec-memory/src/cognition/identity.rs b/crates/typesec-memory/src/cognition/identity.rs index 1316a09..de93f80 100644 --- a/crates/typesec-memory/src/cognition/identity.rs +++ b/crates/typesec-memory/src/cognition/identity.rs @@ -5,7 +5,7 @@ use std::collections::BTreeSet; use sha2::{Digest, Sha256}; use typesec_core::Resource; -use super::digest::{binding_digest, evidence_digest, proposal_digest}; +use super::digest::{binding_digest, evidence_digest}; use super::limits::proposal_output_count; use super::{ CognitionApplyError, CognitionAuditEvidence, CognitionBinding, CognitionEffect, @@ -40,8 +40,8 @@ impl CognitionCommitIdentity { space: &MemorySpace, proposal: &CognitionProposal, binding: &CognitionBinding, + proposal_digest: String, ) -> Result { - let proposal_digest = proposal_digest(proposal)?; let key = CognitionIdempotencyKey::for_authority( &binding.space_id, &binding.subject, diff --git a/crates/typesec-memory/src/cognition/limits.rs b/crates/typesec-memory/src/cognition/limits.rs index ea43e6a..c102cd2 100644 --- a/crates/typesec-memory/src/cognition/limits.rs +++ b/crates/typesec-memory/src/cognition/limits.rs @@ -42,7 +42,7 @@ pub(super) fn validate_projection_count(count: usize) -> Result<(), CognitionApp ) } -pub(super) fn validate_proposal_budget( +pub(super) fn validate_proposal_dimensions( proposal: &CognitionProposal, ) -> Result<(), CognitionApplyError> { enforce( @@ -84,14 +84,7 @@ pub(super) fn validate_proposal_budget( .checked_add(targets) .ok_or(CognitionApplyError::LimitExceeded("mutation count"))?; enforce(operations <= MAX_COGNITION_MUTATIONS, "mutation count")?; - validate_prepared_expansion(proposal, outputs)?; - - let mut writer = BoundedWriter::new(MAX_COGNITION_PROPOSAL_BYTES); - let serialized = serde_json::to_writer(&mut writer, proposal); - if writer.exceeded { - return Err(CognitionApplyError::LimitExceeded("proposal bytes")); - } - serialized.map_err(|error| CognitionApplyError::Serialization(error.to_string())) + validate_prepared_expansion(proposal, outputs) } pub(super) fn proposal_output_count( @@ -299,35 +292,6 @@ fn enforce(allowed: bool, limit: &'static str) -> Result<(), CognitionApplyError } } -struct BoundedWriter { - remaining: usize, - exceeded: bool, -} - -impl BoundedWriter { - fn new(limit: usize) -> Self { - Self { - remaining: limit, - exceeded: false, - } - } -} - -impl Write for BoundedWriter { - fn write(&mut self, bytes: &[u8]) -> io::Result { - if bytes.len() > self.remaining { - self.exceeded = true; - return Err(io::Error::other("cognition proposal exceeds byte limit")); - } - self.remaining -= bytes.len(); - Ok(bytes.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - struct MeasuringWriter { remaining: usize, written: usize, diff --git a/crates/typesec-memory/src/cognition/tests/prepared_commit.rs b/crates/typesec-memory/src/cognition/tests/prepared_commit.rs index bd5a31e..c20f399 100644 --- a/crates/typesec-memory/src/cognition/tests/prepared_commit.rs +++ b/crates/typesec-memory/src/cognition/tests/prepared_commit.rs @@ -123,14 +123,23 @@ fn preparation_rejects_a_type_sec_timestamp_after_preparation() { fn streaming_hashing_matches_the_versioned_canonical_profiles() { let fixture = prepared_fixture(prepared_at()); - let mut canonical_binding = fixture.binding.clone(); + let mut binding = fixture.binding.clone(); + binding.effective_projection.reverse(); + let mut canonical_binding = binding.clone(); canonical_binding.effective_projection.sort(); assert_eq!( - fixture.binding.canonical_digest().unwrap(), + binding.canonical_digest().unwrap(), legacy_digest(b"typesec.marciana.binding.v1\0", &canonical_binding) ); - let mut canonical_proposal = fixture.proposal.clone(); + let mut proposal = fixture.proposal.clone(); + proposal + .binding + .as_mut() + .expect("bound proposal") + .effective_projection + .reverse(); + let mut canonical_proposal = proposal.clone(); canonical_proposal.created_at = DateTime::::UNIX_EPOCH; canonical_proposal .binding @@ -139,7 +148,7 @@ fn streaming_hashing_matches_the_versioned_canonical_profiles() { .effective_projection .sort(); assert_eq!( - fixture.proposal.canonical_digest().unwrap(), + proposal.canonical_digest().unwrap(), legacy_digest(b"typesec.marciana.proposal.v2\0", &canonical_proposal) ); @@ -290,10 +299,13 @@ fn try_prepared_fixture_with_evidence( }), ) .with_binding(binding.clone()); - super::super::validate::validate_proposal_for_application(&proposal) + let proposal_digest = super::super::validate::validate_proposal_for_application(&proposal) .expect("valid cognition proposal"); let identity = super::super::identity::CognitionCommitIdentity::from_validated( - &space, &proposal, &binding, + &space, + &proposal, + &binding, + proposal_digest, ) .expect("commit identity"); let authority = authority_for(&binding); diff --git a/crates/typesec-memory/src/cognition/types.rs b/crates/typesec-memory/src/cognition/types.rs index ca4ef31..c7bd857 100644 --- a/crates/typesec-memory/src/cognition/types.rs +++ b/crates/typesec-memory/src/cognition/types.rs @@ -10,7 +10,7 @@ use crate::space::MemoryId; use crate::store::{MemoryStore, StoreError}; use super::PreparedCognitionCommit; -use super::canonical::{is_canonical_sha256, is_canonical_text}; +use super::canonical::{canonical_projection, is_canonical_sha256, is_canonical_text}; use super::limits::validate_projection_count; /// Immutable authority and input evidence a cognition proposal must echo. @@ -48,6 +48,12 @@ pub struct CognitionBinding { impl CognitionBinding { pub(crate) fn validate(&self) -> Result<(), CognitionApplyError> { + self.validate_and_canonical_projection().map(drop) + } + + pub(super) fn validate_and_canonical_projection( + &self, + ) -> Result, CognitionApplyError> { for (name, value) in [ ("spaceId", self.space_id.as_str()), ("subject", self.subject.as_str()), @@ -88,15 +94,13 @@ impl CognitionBinding { "effectiveProjection".to_owned(), )); } - let mut canonical = self.effective_projection.clone(); - canonical.sort(); - canonical.dedup(); - if canonical.len() != self.effective_projection.len() { + let canonical = canonical_projection(&self.effective_projection); + if canonical.windows(2).any(|pair| pair[0] == pair[1]) { return Err(CognitionApplyError::InvalidBinding( "effectiveProjection contains duplicates".to_owned(), )); } - Ok(()) + Ok(canonical) } } diff --git a/crates/typesec-memory/src/cognition/validate.rs b/crates/typesec-memory/src/cognition/validate.rs index c1cae8b..06247fe 100644 --- a/crates/typesec-memory/src/cognition/validate.rs +++ b/crates/typesec-memory/src/cognition/validate.rs @@ -5,8 +5,12 @@ use typesec_core::policy::RequestContext; use typesec_core::{CanWrite, Capability, Resource}; use super::CognitionEffect; -use super::canonical::{is_canonical_sha256, is_canonical_text}; -use super::limits::{CognitionSourceBudget, MAX_COGNITION_SOURCE_COUNT, validate_proposal_budget}; +use super::canonical::{canonical_projection, is_canonical_sha256, is_canonical_text}; +use super::digest::proposal_digest_with_wire_limit; +use super::limits::{ + CognitionSourceBudget, MAX_COGNITION_PROPOSAL_BYTES, MAX_COGNITION_SOURCE_COUNT, + validate_proposal_dimensions, +}; use super::types::{CognitionApplyError, CognitionAuthorityEvidence, CognitionBinding}; use crate::CognitionProposal; use crate::error::MemoryError; @@ -25,23 +29,39 @@ pub(super) fn required_purpose(context: &RequestContext) -> Result<&str, Cogniti .ok_or(CognitionApplyError::MissingPurpose) } -pub(super) fn validate_proposal_shape( +pub(super) fn validate_decoded_proposal( proposal: &CognitionProposal, ) -> Result<(), CognitionApplyError> { - validate_proposal(proposal, false) + validate_proposal_dimensions(proposal)?; + validate_proposal_structure(proposal, false) +} + +pub(super) fn validate_proposal_identity( + proposal: &CognitionProposal, +) -> Result { + validate_and_digest_proposal(proposal, false) } pub(super) fn validate_proposal_for_application( proposal: &CognitionProposal, -) -> Result<(), CognitionApplyError> { - validate_proposal(proposal, true) +) -> Result { + validate_and_digest_proposal(proposal, true) +} + +fn validate_and_digest_proposal( + proposal: &CognitionProposal, + require_mutation: bool, +) -> Result { + validate_proposal_dimensions(proposal)?; + let digest = proposal_digest_with_wire_limit(proposal, MAX_COGNITION_PROPOSAL_BYTES)?; + validate_proposal_structure(proposal, require_mutation)?; + Ok(digest) } -fn validate_proposal( +fn validate_proposal_structure( proposal: &CognitionProposal, require_mutation: bool, ) -> Result<(), CognitionApplyError> { - validate_proposal_budget(proposal)?; if ![ CognitionProposal::MIN_SCHEMA_VERSION, CognitionProposal::SCHEMA_VERSION, @@ -214,12 +234,6 @@ pub(super) fn load_sources( Ok(records) } -fn canonical_projection(projection: &[String]) -> Vec<&str> { - let mut canonical: Vec<_> = projection.iter().map(String::as_str).collect(); - canonical.sort_unstable(); - canonical -} - fn validate_source_ids(source_ids: &[MemoryId]) -> Result<(), CognitionApplyError> { if source_ids.len() > MAX_COGNITION_SOURCE_COUNT { return Err(CognitionApplyError::LimitExceeded("source count")); diff --git a/crates/typesec-memory/src/index.rs b/crates/typesec-memory/src/index.rs index fa4b960..41679ff 100644 --- a/crates/typesec-memory/src/index.rs +++ b/crates/typesec-memory/src/index.rs @@ -19,6 +19,9 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::sync::{Arc, RwLock}; + use crate::label::Label; use crate::space::MemoryId; @@ -125,8 +128,20 @@ pub trait SemanticIndex: Send + Sync { /// wiring and gives tests a stable ranking. #[derive(Default)] pub struct KeywordIndex { - entries: - std::sync::RwLock>>, + entries: RwLock, +} + +#[derive(Default)] +struct KeywordEntries { + documents: Vec>, + document_keys: HashMap, usize>, + vacant_keys: Vec, + postings: HashMap>, +} + +struct KeywordDocument { + id: Arc, + tokens: BTreeSet, } impl KeywordIndex { @@ -135,28 +150,97 @@ impl KeywordIndex { Self::default() } - fn tokens(text: &str) -> std::collections::BTreeSet { + fn tokens(text: &str) -> BTreeSet { text.split(|c: char| !c.is_alphanumeric()) .filter(|t| !t.is_empty()) .map(str::to_lowercase) .collect() } + + fn remove_posting(entries: &mut KeywordEntries, key: usize, token: &str) { + let remove_posting = entries.postings.get_mut(token).is_some_and(|keys| { + keys.remove(&key); + keys.is_empty() + }); + if remove_posting { + entries.postings.remove(token); + } + } + + fn remove_postings(entries: &mut KeywordEntries, key: usize, tokens: BTreeSet) { + for token in tokens { + Self::remove_posting(entries, key, &token); + } + } } impl SemanticIndex for KeywordIndex { fn index(&self, id: &MemoryId, _label: Label, text: &str) -> Result<(), IndexError> { - self.entries + let tokens = Self::tokens(text); + let mut entries = self + .entries .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(id.clone(), Self::tokens(text)); + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(&key) = entries.document_keys.get(id) { + let document = entries.documents[key] + .as_mut() + .expect("keyword index document key must be valid"); + if document.tokens == tokens { + return Ok(()); + } + let old_tokens = std::mem::take(&mut document.tokens); + for token in old_tokens.difference(&tokens) { + Self::remove_posting(&mut entries, key, token); + } + for token in tokens.difference(&old_tokens) { + entries + .postings + .entry(token.clone()) + .or_default() + .insert(key); + } + entries.documents[key] + .as_mut() + .expect("keyword index document key must be valid") + .tokens = tokens; + return Ok(()); + } + + let key = entries.vacant_keys.pop().unwrap_or(entries.documents.len()); + for token in &tokens { + entries + .postings + .entry(token.clone()) + .or_default() + .insert(key); + } + let id = Arc::new(id.clone()); + let document = KeywordDocument { + id: Arc::clone(&id), + tokens, + }; + entries.document_keys.insert(id, key); + if key == entries.documents.len() { + entries.documents.push(Some(document)); + } else { + entries.documents[key] = Some(document); + } Ok(()) } fn remove(&self, id: &MemoryId) -> Result<(), IndexError> { - self.entries + let mut entries = self + .entries .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remove(id); + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((_, key)) = entries.document_keys.remove_entry(id) else { + return Ok(()); + }; + let document = entries.documents[key] + .take() + .expect("keyword index document key must be valid"); + Self::remove_postings(&mut entries, key, document.tokens); + entries.vacant_keys.push(key); Ok(()) } @@ -169,14 +253,52 @@ impl SemanticIndex for KeywordIndex { .entries .read() .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut scored: Vec<(usize, MemoryId)> = entries + let posting_visits = needle .iter() - .map(|(id, tokens)| (needle.intersection(tokens).count(), id.clone())) - .filter(|(score, _)| *score > 0) - .collect(); + .filter_map(|token| entries.postings.get(token)) + .map(HashSet::len) + .sum::(); + let mut scored = if posting_visits >= entries.document_keys.len() { + entries + .documents + .iter() + .flatten() + .map(|document| { + ( + needle.intersection(&document.tokens).count(), + document.id.as_ref(), + ) + }) + .filter(|(score, _)| *score > 0) + .collect::>() + } else { + let mut scores = HashMap::::new(); + for token in &needle { + if let Some(keys) = entries.postings.get(token) { + for &key in keys { + *scores.entry(key).or_default() += 1; + } + } + } + scores + .into_iter() + .map(|(key, score)| { + let document = entries.documents[key] + .as_ref() + .expect("keyword index posting key must be valid"); + (score, document.id.as_ref()) + }) + .collect::>() + }; // Best score first; ties broken by id for determinism. - scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); - Ok(scored.into_iter().take(limit).map(|(_, id)| id).collect()) + let score_order = + |a: &(usize, &MemoryId), b: &(usize, &MemoryId)| b.0.cmp(&a.0).then(a.1.cmp(b.1)); + if limit < scored.len() { + scored.select_nth_unstable_by(limit, score_order); + scored.truncate(limit); + } + scored.sort_unstable_by(score_order); + Ok(scored.into_iter().map(|(_, id)| id.clone()).collect()) } } diff --git a/crates/typesec-memory/src/record.rs b/crates/typesec-memory/src/record.rs index 58c716d..d9b86cc 100644 --- a/crates/typesec-memory/src/record.rs +++ b/crates/typesec-memory/src/record.rs @@ -254,11 +254,6 @@ impl StoredRecord { &self.content } - /// Crate-internal: lowercased text for the shared store text filter. - pub(crate) fn content_text_lower(&self) -> String { - self.content.text.to_lowercase() - } - /// Crate-internal constructor used by the vault when persisting a draft. #[allow(clippy::too_many_arguments)] pub(crate) fn assemble( diff --git a/crates/typesec-memory/src/store.rs b/crates/typesec-memory/src/store.rs index 2539e08..6a7bcf6 100644 --- a/crates/typesec-memory/src/store.rs +++ b/crates/typesec-memory/src/store.rs @@ -113,7 +113,7 @@ impl StoreQuery { return false; } if let Some(needle) = &self.text_contains - && !record.content_text_lower().contains(&needle.to_lowercase()) + && !contains_case_insensitive(&record.content().text, needle) { return false; } @@ -130,6 +130,48 @@ impl StoreQuery { } } +/// Preserve Unicode lowercase matching while keeping the overwhelmingly common +/// ASCII path allocation-free. `eq_ignore_ascii_case` applies to arbitrary byte +/// slices, so this also avoids constructing a lowercase copy of every record in +/// a store scan. +fn contains_case_insensitive(haystack: &str, needle: &str) -> bool { + if needle.is_empty() { + return true; + } + if haystack.is_ascii() && needle.is_ascii() { + return haystack + .as_bytes() + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle.as_bytes())); + } + haystack.to_lowercase().contains(&needle.to_lowercase()) +} + +pub(crate) fn query_records<'a>( + records: impl Iterator, + query: &StoreQuery, +) -> Vec { + if query.limit == Some(0) { + return Vec::new(); + } + + let mut matches = records + .filter(|record| query.matches(record)) + .collect::>(); + if let Some(limit) = query.limit + && limit < matches.len() + { + matches.select_nth_unstable_by(limit, recent_record_order); + matches.truncate(limit); + } + matches.sort_unstable_by(recent_record_order); + matches.into_iter().cloned().collect() +} + +fn recent_record_order(a: &&StoredRecord, b: &&StoredRecord) -> std::cmp::Ordering { + b.observed_at.cmp(&a.observed_at).then(b.id.cmp(&a.id)) +} + /// One write within an atomic [`apply_batch`](MemoryStore::apply_batch). #[derive(Debug, Clone, Serialize, Deserialize)] pub enum StoreBatchOp { diff --git a/crates/typesec-memory/src/store/grust.rs b/crates/typesec-memory/src/store/grust.rs index 7decdb8..ec8a743 100644 --- a/crates/typesec-memory/src/store/grust.rs +++ b/crates/typesec-memory/src/store/grust.rs @@ -23,7 +23,7 @@ use std::sync::{PoisonError, RwLock}; use chrono::{DateTime, Utc}; use grust::prelude::{Graph, Node, NodeId, Value}; -use super::{MemoryStore, StoreError, StoreQuery}; +use super::{MemoryStore, StoreError, StoreQuery, query_records}; use crate::record::StoredRecord; use crate::space::MemoryId; @@ -135,17 +135,7 @@ impl MemoryStore for GrustMemoryStore { fn query(&self, query: &StoreQuery) -> Result, StoreError> { let inner = self.read(); - let mut out: Vec = inner - .records - .values() - .filter(|record| query.matches(record)) - .cloned() - .collect(); - out.sort_by(|a, b| b.observed_at.cmp(&a.observed_at).then(b.id.cmp(&a.id))); - if let Some(limit) = query.limit { - out.truncate(limit); - } - Ok(out) + Ok(query_records(inner.records.values(), query)) } fn invalidate(&self, id: &MemoryId, at: DateTime) -> Result<(), StoreError> { diff --git a/crates/typesec-memory/src/store/memory.rs b/crates/typesec-memory/src/store/memory.rs index 91eb62f..831b50c 100644 --- a/crates/typesec-memory/src/store/memory.rs +++ b/crates/typesec-memory/src/store/memory.rs @@ -10,7 +10,7 @@ use std::sync::{PoisonError, RwLock}; use chrono::{DateTime, Utc}; -use super::{MemoryStore, StoreError, StoreQuery}; +use super::{MemoryStore, StoreError, StoreQuery, query_records}; use crate::record::StoredRecord; use crate::space::MemoryId; @@ -58,17 +58,7 @@ impl MemoryStore for InMemoryStore { fn query(&self, query: &StoreQuery) -> Result, StoreError> { let guard = self.read(); - let mut out: Vec = guard - .values() - .filter(|record| query.matches(record)) - .cloned() - .collect(); - // Deterministic order for tests/ranking: newest observation first. - out.sort_by(|a, b| b.observed_at.cmp(&a.observed_at).then(b.id.cmp(&a.id))); - if let Some(limit) = query.limit { - out.truncate(limit); - } - Ok(out) + Ok(query_records(guard.values(), query)) } fn invalidate(&self, id: &MemoryId, at: DateTime) -> Result<(), StoreError> { diff --git a/crates/typesec-memory/src/store/tests.rs b/crates/typesec-memory/src/store/tests.rs index 2e1c1be..b452908 100644 --- a/crates/typesec-memory/src/store/tests.rs +++ b/crates/typesec-memory/src/store/tests.rs @@ -55,6 +55,21 @@ fn text_and_quarantine_filters() { assert!(include.matches(&quarantined)); } +#[test] +fn text_filter_preserves_unicode_lowercase_semantics() { + let unicode = StoreQuery { + text_contains: Some("CAFÉ".into()), + ..Default::default() + }; + assert!(unicode.matches(&rec("m1", Label::Public, "Rendez-vous au café"))); + + let empty = StoreQuery { + text_contains: Some(String::new()), + ..Default::default() + }; + assert!(empty.matches(&rec("m2", Label::Public, "anything"))); +} + #[test] fn purpose_filter_allows_untagged_and_overlapping() { let q = StoreQuery { @@ -105,3 +120,34 @@ fn in_memory_store_roundtrips_and_invalidates() { assert!(store.tombstone(&MemoryId::from_string("m2")).unwrap()); assert!(!store.tombstone(&MemoryId::from_string("nope")).unwrap()); } + +#[test] +fn in_memory_store_limit_preserves_rank_order() { + let store = InMemoryStore::new(); + for id in ["m1", "m4", "m2", "m3"] { + store.put(rec(id, Label::Public, id)).unwrap(); + } + + let limited = store + .query(&StoreQuery { + limit: Some(2), + ..Default::default() + }) + .unwrap(); + assert_eq!( + limited + .iter() + .map(|record| record.id.as_str()) + .collect::>(), + ["m4", "m3"] + ); + assert!( + store + .query(&StoreQuery { + limit: Some(0), + ..Default::default() + }) + .unwrap() + .is_empty() + ); +} diff --git a/crates/typesec-odrl/benches/odrl_check.rs b/crates/typesec-odrl/benches/odrl_check.rs index f6da893..724acd5 100644 --- a/crates/typesec-odrl/benches/odrl_check.rs +++ b/crates/typesec-odrl/benches/odrl_check.rs @@ -1,10 +1,10 @@ -use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; use typesec_odrl::OdrlEngine; use typesec_odrl::constraint::ConstraintContext; -fn odrl_policy() -> String { +fn odrl_policy(policy_count: usize) -> String { let mut yaml = String::from("policies:\n"); - for idx in 0..10 { + for idx in 0..policy_count { yaml.push_str(&format!( r#" - uid: "policy-{idx}" type: Set @@ -23,24 +23,63 @@ fn odrl_policy() -> String { yaml } -fn bench_odrl_check_with_constraints(c: &mut Criterion) { - let yaml = odrl_policy(); - let engine = OdrlEngine::from_yaml(&yaml).expect("policy"); +fn irrelevant_policy(policy_count: usize) -> String { + let mut yaml = String::from("policies:\n"); + for idx in 0..policy_count { + yaml.push_str(&format!( + r#" - uid: "irrelevant-{idx}" + type: Set + rules: + - type: permission + assignee: "agent:{idx}" + action: read + target: "reports/{idx}/*" +"# + )); + } + yaml +} + +fn bench_odrl_checks(c: &mut Criterion) { let context = ConstraintContext::default().with_purpose("analytics"); + let mut group = c.benchmark_group("odrl_check"); + group.throughput(Throughput::Elements(1)); - c.bench_function("bench_odrl_check_with_constraints", |b| { + for policy_count in [1, 10, 100] { + let yaml = odrl_policy(policy_count); + let engine = OdrlEngine::from_yaml(&yaml).expect("policy"); + let resource = format!("reports/{}/q1", policy_count - 1); + group.bench_with_input( + BenchmarkId::new("constrained_last_target_hit", policy_count), + &policy_count, + |b, _| { + b.iter(|| { + black_box(engine.check_with_context( + black_box("agent:bench"), + black_box("read"), + black_box(&resource), + black_box(&context), + )) + }) + }, + ); + } + + let yaml = irrelevant_policy(1_000); + let engine = OdrlEngine::from_yaml(&yaml).expect("policy"); + group.bench_function("indexed_miss_1000_irrelevant_rules", |b| { b.iter(|| { - for _ in 0..1_000 { - let _ = black_box(engine.check_with_context( - black_box("agent:bench"), - black_box("read"), - black_box("reports/7/q1"), - black_box(&context), - )); - } + black_box(engine.check_with_context( + black_box("agent:bench"), + black_box("read"), + black_box("reports/missing/q1"), + black_box(&context), + )) }) }); + + group.finish(); } -criterion_group!(benches, bench_odrl_check_with_constraints); +criterion_group!(benches, bench_odrl_checks); criterion_main!(benches); diff --git a/crates/typesec-odrl/src/engine.rs b/crates/typesec-odrl/src/engine.rs index ffc6a53..f4ee608 100644 --- a/crates/typesec-odrl/src/engine.rs +++ b/crates/typesec-odrl/src/engine.rs @@ -2,8 +2,6 @@ mod index; -use std::collections::HashMap; - use tracing::debug; use typesec_core::{ ResourceId, SubjectId, @@ -16,7 +14,7 @@ use crate::{ model::{OdrlDocument, OdrlRuleType}, }; use index::{ - CompiledTarget, RuleIndex, RuleRef, WildcardActionIndex, build_rule_index, compile_targets, + CompiledTargets, RuleIndex, RuleRef, WildcardActionIndex, build_rule_index, compile_targets, }; struct RuleMatch { @@ -54,9 +52,9 @@ pub struct OdrlEngine { exact_rules: RuleIndex, /// Same-assignee wildcard action (`use`) rules. wildcard_action_rules: WildcardActionIndex, - /// Each rule's target glob, compiled once at load and keyed by + /// Each rule's target glob, compiled once at load and indexed by /// `(policy_index, rule_index)`. - compiled_targets: HashMap<(usize, usize), CompiledTarget>, + compiled_targets: CompiledTargets, /// Default context applied to every check (can be overridden per-check). default_context: ConstraintContext, } @@ -122,7 +120,7 @@ impl OdrlEngine { "odrl check" ); - let scan = self.scan_candidates(&candidates, action, resource, ctx); + let scan = self.scan_candidates(candidates, action, resource, ctx); build_decision(scan, subject, action, resource) } @@ -130,7 +128,7 @@ impl OdrlEngine { /// matching prohibition. Pure: emits no audit and renders no verdict. fn scan_candidates( &self, - candidates: &[RuleRef], + candidates: impl Iterator, action: &str, resource: &str, ctx: &ConstraintContext, @@ -144,10 +142,8 @@ impl OdrlEngine { let rule = &policy.rules[rule_ref.rule_index]; // Check target (glob) matches, using the pattern compiled at load. - let target_matches = self - .compiled_targets - .get(&(rule_ref.policy_index, rule_ref.rule_index)) - .is_some_and(|target| target.matches(resource)); + let target_matches = + self.compiled_targets[rule_ref.policy_index][rule_ref.rule_index].matches(resource); if !target_matches { continue; } @@ -208,28 +204,79 @@ impl OdrlEngine { } } - fn candidate_rules(&self, subject: &str, action: &str) -> Vec { - let mut candidates = Vec::new(); - - if let Some(exact) = self + fn candidate_rules<'a>(&'a self, subject: &str, action: &str) -> RuleCandidates<'a> { + let exact = self .exact_rules - .get(&(subject.to_owned(), action.to_owned())) - { - candidates.extend_from_slice(exact); - } + .get(subject) + .and_then(|actions| actions.get(action)) + .map(Vec::as_slice) + .unwrap_or_default(); + let wildcard = self + .wildcard_action_rules + .get(subject) + .map(Vec::as_slice) + .unwrap_or_default(); + RuleCandidates::new(exact, wildcard) + } +} - if let Some(wildcard) = self.wildcard_action_rules.get(subject) { - candidates.extend_from_slice(wildcard); +/// Allocation-free merge of exact-action and wildcard-action candidates. Both +/// slices are already in document order, so comparing ordinals preserves +/// deterministic ODRL evaluation without building and sorting a temporary vec. +struct RuleCandidates<'a> { + exact: &'a [RuleRef], + wildcard: &'a [RuleRef], + exact_index: usize, + wildcard_index: usize, +} + +impl<'a> RuleCandidates<'a> { + fn new(exact: &'a [RuleRef], wildcard: &'a [RuleRef]) -> Self { + Self { + exact, + wildcard, + exact_index: 0, + wildcard_index: 0, } + } +} + +impl Iterator for RuleCandidates<'_> { + type Item = RuleRef; - if candidates.len() > 1 { - candidates.sort_by_key(|rule_ref| rule_ref.ordinal); + fn next(&mut self) -> Option { + let exact = self.exact.get(self.exact_index); + let wildcard = self.wildcard.get(self.wildcard_index); + match (exact, wildcard) { + (Some(exact), Some(wildcard)) if exact.ordinal <= wildcard.ordinal => { + self.exact_index += 1; + Some(*exact) + } + (Some(_), Some(wildcard)) => { + self.wildcard_index += 1; + Some(*wildcard) + } + (Some(exact), None) => { + self.exact_index += 1; + Some(*exact) + } + (None, Some(wildcard)) => { + self.wildcard_index += 1; + Some(*wildcard) + } + (None, None) => None, } + } - candidates + fn size_hint(&self) -> (usize, Option) { + let remaining = + self.exact.len() - self.exact_index + self.wildcard.len() - self.wildcard_index; + (remaining, Some(remaining)) } } +impl ExactSizeIterator for RuleCandidates<'_> {} + /// Render an ODRL verdict and the full audit trail for a [`ScanResult`]. /// /// Pure (no logging) so the events are testable. Prohibition wins over diff --git a/crates/typesec-odrl/src/engine/index.rs b/crates/typesec-odrl/src/engine/index.rs index 9db6ece..b3f93da 100644 --- a/crates/typesec-odrl/src/engine/index.rs +++ b/crates/typesec-odrl/src/engine/index.rs @@ -8,9 +8,10 @@ use typesec_core::glob::GlobPattern; use crate::model::{OdrlDocument, RuleAction}; -pub(super) type RuleKey = (String, String); -pub(super) type RuleIndex = HashMap>; +pub(super) type ActionRuleIndex = HashMap>; +pub(super) type RuleIndex = HashMap; pub(super) type WildcardActionIndex = HashMap>; +pub(super) type CompiledTargets = Vec>; /// A rule's target, compiled once at load: the raw target string plus a /// [`GlobPattern`] over its `asset:`-stripped form. @@ -53,18 +54,18 @@ impl CompiledTarget { } } -/// Compile every rule's target once, keyed by `(policy_index, rule_index)`. -pub(super) fn compile_targets(doc: &OdrlDocument) -> HashMap<(usize, usize), CompiledTarget> { - let mut targets = HashMap::new(); - for (policy_index, policy) in doc.policies.iter().enumerate() { - for (rule_index, rule) in policy.rules.iter().enumerate() { - targets.insert( - (policy_index, rule_index), - CompiledTarget::compile(&rule.target), - ); - } - } - targets +/// Compile every rule's target once, indexed by `(policy_index, rule_index)`. +pub(super) fn compile_targets(doc: &OdrlDocument) -> CompiledTargets { + doc.policies + .iter() + .map(|policy| { + policy + .rules + .iter() + .map(|rule| CompiledTarget::compile(&rule.target)) + .collect() + }) + .collect() } /// A pointer into the parsed document identifying a single rule, plus the @@ -98,10 +99,9 @@ pub(super) fn build_rule_index(doc: &OdrlDocument) -> (RuleIndex, WildcardAction .push(rule_ref); } else { exact_rules - .entry(( - rule.assignee.clone(), - rule.action.as_permission_name().to_owned(), - )) + .entry(rule.assignee.clone()) + .or_default() + .entry(rule.action.as_permission_name().to_owned()) .or_default() .push(rule_ref); } diff --git a/crates/typesec-odrl/src/engine/tests.rs b/crates/typesec-odrl/src/engine/tests.rs index 054cd57..745096f 100644 --- a/crates/typesec-odrl/src/engine/tests.rs +++ b/crates/typesec-odrl/src/engine/tests.rs @@ -65,14 +65,16 @@ fn exact_rule_index_is_built_at_construction() { let e = engine(); assert_eq!( e.exact_rules - .get(&("agent:summarizer".to_owned(), "read".to_owned())) + .get("agent:summarizer") + .and_then(|actions| actions.get("read")) .expect("read rule indexed") .len(), 1 ); assert_eq!( e.exact_rules - .get(&("agent:summarizer".to_owned(), "ai:exfiltrate".to_owned())) + .get("agent:summarizer") + .and_then(|actions| actions.get("ai:exfiltrate")) .expect("exfiltrate rule indexed") .len(), 1 diff --git a/crates/typesec-python/src/lib.rs b/crates/typesec-python/src/lib.rs index e5b88a1..aa242b4 100644 --- a/crates/typesec-python/src/lib.rs +++ b/crates/typesec-python/src/lib.rs @@ -185,7 +185,7 @@ mod tests { let allowed = gate.call_method1( "check", - ("agent:executive-chief", "write", "company/strategy"), + ("agent:executive-chief", "write", "company/acme/org-graph"), )?; assert!(decision_allowed(&allowed)?); diff --git a/crates/typesec-rbac/benches/rbac_check.rs b/crates/typesec-rbac/benches/rbac_check.rs index 48c90b0..dbfcd8a 100644 --- a/crates/typesec-rbac/benches/rbac_check.rs +++ b/crates/typesec-rbac/benches/rbac_check.rs @@ -1,4 +1,4 @@ -use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; use typesec_core::{PolicyEngine, ResourceId, SubjectId}; use typesec_rbac::RbacEngine; @@ -24,42 +24,72 @@ fn miss_policy() -> String { yaml } -fn bench_rbac_check_hit(c: &mut Criterion) { +fn policy_with_permissions(count: usize) -> String { + let permissions = (0..count) + .map(|idx| format!("action_{idx}")) + .collect::>() + .join(", "); + format!( + r#" +roles: + - name: scaled + permissions: [{permissions}] + resources: ["reports/*"] +assignments: + - subject: "agent:bench" + roles: [scaled] +"# + ) +} + +fn bench_rbac_checks(c: &mut Criterion) { + let mut group = c.benchmark_group("rbac_check"); + group.throughput(Throughput::Elements(1)); + let engine = RbacEngine::from_yaml(HIT_POLICY).expect("policy"); let subject = SubjectId::from("agent:bench"); let resource = ResourceId::from("reports/q1"); - c.bench_function("bench_rbac_check_hit", |b| { + group.bench_function("exact_hit", |b| { b.iter(|| { - for _ in 0..1_000 { - let _ = black_box(engine.check( - black_box(&subject), - black_box("read"), - black_box(&resource), - )); - } + black_box(engine.check(black_box(&subject), black_box("read"), black_box(&resource))) }) }); -} -fn bench_rbac_check_miss(c: &mut Criterion) { let yaml = miss_policy(); let engine = RbacEngine::from_yaml(&yaml).expect("policy"); - let subject = SubjectId::from("agent:bench"); - let resource = ResourceId::from("reports/q1"); - c.bench_function("bench_rbac_check_miss", |b| { + group.bench_function("unassigned_subject_miss", |b| { b.iter(|| { - for _ in 0..1_000 { - let _ = black_box(engine.check( - black_box(&subject), - black_box("write"), - black_box(&resource), - )); - } + black_box(engine.check( + black_box(&subject), + black_box("write"), + black_box(&resource), + )) }) }); + + for permission_count in [1, 16, 64] { + let policy = policy_with_permissions(permission_count); + let engine = RbacEngine::from_yaml(&policy).expect("scaled policy"); + let action = format!("action_{}", permission_count - 1); + group.bench_with_input( + BenchmarkId::new("last_permission_hit", permission_count), + &permission_count, + |b, _| { + b.iter(|| { + black_box(engine.check( + black_box(&subject), + black_box(&action), + black_box(&resource), + )) + }) + }, + ); + } + + group.finish(); } -criterion_group!(benches, bench_rbac_check_hit, bench_rbac_check_miss); +criterion_group!(benches, bench_rbac_checks); criterion_main!(benches); diff --git a/crates/typesec-rbac/src/engine.rs b/crates/typesec-rbac/src/engine.rs index e6e21db..43ae912 100644 --- a/crates/typesec-rbac/src/engine.rs +++ b/crates/typesec-rbac/src/engine.rs @@ -20,26 +20,82 @@ use flatten::flatten_role; /// - Effective permissions per role (with inheritance flattened). /// - Subject → role mappings. /// -/// Every `check()` call does O(roles × patterns) work — fast enough for -/// the sizes of policies used in AI agent deployments. +/// Exact-subject checks use a compact permission lookup followed by only that +/// permission's resource patterns. Wildcard-subject assignments are evaluated +/// separately because their subject globs necessarily depend on the request. pub struct RbacEngine { - /// Subject → set of effective (permission, resource_pattern) pairs. - subject_grants: HashMap>, + /// Subject → compact, permission-indexed compiled resource grants. + subject_grants: HashMap, /// Glob subject pattern → set of effective grants. - wildcard_subject_grants: Vec<(GlobPattern, Vec)>, + wildcard_subject_grants: Vec<(GlobPattern, CompiledGrants)>, } -/// A grant with its glob patterns validated and compiled once at load time. -/// -/// Compiling here (rather than per `check()`) both surfaces pattern typos as -/// load errors — a malformed pattern would otherwise silently never match, -/// i.e. silently deny — and avoids re-parsing the glob on every check. -#[derive(Debug, Clone)] +/// A permission and its resource patterns, all compiled at policy load. +#[derive(Debug)] struct CompiledGrant { permission: String, resource_patterns: Vec, } +/// Sorted effective grants. Tiny policies stay on a one-or-two-comparison +/// linear path; larger policies use binary search without a second hash lookup. +#[derive(Debug, Default)] +struct CompiledGrants { + grants: Vec, +} + +impl CompiledGrants { + const LINEAR_SEARCH_LIMIT: usize = 8; + + fn insert(&mut self, permission: String, patterns: Vec) { + if let Some(existing) = self + .grants + .iter_mut() + .find(|grant| grant.permission == permission) + { + existing.resource_patterns.extend(patterns); + } else { + self.grants.push(CompiledGrant { + permission, + resource_patterns: patterns, + }); + } + } + + fn extend(&mut self, other: Self) { + for grant in other.grants { + self.insert(grant.permission, grant.resource_patterns); + } + self.sort(); + } + + fn sort(&mut self) { + self.grants + .sort_unstable_by(|left, right| left.permission.cmp(&right.permission)); + } + + fn is_empty(&self) -> bool { + self.grants.is_empty() + } + + fn allows(&self, action: &str, resource: &str) -> bool { + let grant = if self.grants.len() <= Self::LINEAR_SEARCH_LIMIT { + self.grants.iter().find(|grant| grant.permission == action) + } else { + self.grants + .binary_search_by(|grant| grant.permission.as_str().cmp(action)) + .ok() + .map(|index| &self.grants[index]) + }; + grant.is_some_and(|grant| { + grant + .resource_patterns + .iter() + .any(|pattern| pattern.matches(resource)) + }) + } +} + impl RbacEngine { /// Build an engine from a validated [`RbacPolicy`]. /// @@ -59,34 +115,35 @@ impl RbacEngine { // Step 2: build subject → grants mapping, compiling patterns up front // so invalid globs fail the policy load instead of silently denying. - let mut subject_grants: HashMap> = HashMap::new(); - let mut wildcard_subject_grants: Vec<(GlobPattern, Vec)> = Vec::new(); + let mut subject_grants: HashMap = HashMap::new(); + let mut wildcard_subject_grants: Vec<(GlobPattern, CompiledGrants)> = Vec::new(); for assignment in &policy.assignments { - let mut all_grants: Vec = Vec::new(); + let mut all_grants = CompiledGrants::default(); for role_name in &assignment.roles { if let Some(grants) = effective_roles.get(role_name) { for grant in grants { - all_grants.push(CompiledGrant { - permission: grant.permission.clone(), - resource_patterns: grant + all_grants.insert( + grant.permission.clone(), + grant .resource_patterns .iter() .map(|p| GlobPattern::compile(p, "resource")) - .collect::>()?, - }); + .collect::, _>>()?, + ); } } } + all_grants.sort(); if is_glob_pattern(&assignment.subject) { wildcard_subject_grants.push(( GlobPattern::compile(&assignment.subject, "subject")?, all_grants, )); } else { - subject_grants + let subject = subject_grants .entry(assignment.subject.clone()) - .or_default() - .extend(all_grants); + .or_default(); + subject.extend(all_grants); } } @@ -109,22 +166,21 @@ impl PolicyEngine for RbacEngine { let resource = resource.as_str(); debug!(subject, action, resource, "rbac check"); - let exact_grants = self.subject_grants.get(subject).into_iter().flatten(); - let wildcard_grants = self - .wildcard_subject_grants - .iter() - .filter(|(pattern, _)| pattern.matches(subject)) - .flat_map(|(_, grants)| grants); - let mut matched_subject = false; - for grant in exact_grants.chain(wildcard_grants) { - matched_subject = true; - if grant.permission == action { - for pattern in &grant.resource_patterns { - if pattern.matches(resource) { - return PolicyResult::Allow; - } - } + if let Some(grants) = self.subject_grants.get(subject) { + matched_subject = !grants.is_empty(); + if grants.allows(action, resource) { + return PolicyResult::Allow; + } + } + + for (subject_pattern, grants) in &self.wildcard_subject_grants { + if !subject_pattern.matches(subject) { + continue; + } + matched_subject |= !grants.is_empty(); + if grants.allows(action, resource) { + return PolicyResult::Allow; } } diff --git a/crates/typesec-rbac/src/graph_policy/tests.rs b/crates/typesec-rbac/src/graph_policy/tests.rs index 471258e..4bc0e3a 100644 --- a/crates/typesec-rbac/src/graph_policy/tests.rs +++ b/crates/typesec-rbac/src/graph_policy/tests.rs @@ -71,6 +71,8 @@ graph_policy: no_cycle: true "#; +const COMPANY_YAML: &str = include_str!("../../../../policies/graph-corporate-example.yaml"); + fn engine() -> GraphPolicyEngine { GraphPolicyEngine::from_yaml(YAML).expect("graph policy should load") } @@ -95,6 +97,32 @@ fn role_can_write_non_executive_employee_node() { ); } +#[test] +fn executive_graph_admin_can_persist_a_tenant_org_graph() { + let engine = GraphPolicyEngine::from_yaml(COMPANY_YAML).expect("company policy should load"); + assert_eq!( + engine.check( + &SubjectId::from("agent:executive-chief"), + "write", + &ResourceId::from("company/acme/org-graph"), + ), + PolicyResult::Allow + ); +} + +#[test] +fn executive_graph_admin_does_not_gain_unrelated_company_writes() { + let engine = GraphPolicyEngine::from_yaml(COMPANY_YAML).expect("company policy should load"); + assert!(matches!( + engine.check( + &SubjectId::from("agent:executive-chief"), + "write", + &ResourceId::from("company/acme/payroll"), + ), + PolicyResult::Deny(_) + )); +} + #[test] fn role_cannot_write_executive_employee_node() { assert!(matches!( diff --git a/docs/book/dist/VERSION.md b/docs/book/dist/VERSION.md index 18d61eb..18ba5e5 100644 --- a/docs/book/dist/VERSION.md +++ b/docs/book/dist/VERSION.md @@ -2,23 +2,23 @@ title: Typesec subtitle: Type-Level Security for Agentic AI title_stem: typesec edition: full -version: 0.13.0 -version_stamp: 0.13.0-1926f18c -source_commit: 1926f18c -built_at: 2026-08-06T06:29:46Z -toolchain_lock: ../firstpair/publishing/toolchain.lock.json +version: 0.13.1 +version_stamp: 0.13.1-a45ecd86 +source_commit: a45ecd86 +built_at: 2026-08-09T11:35:57Z +toolchain_lock: ../../src/firstpair/publishing/toolchain.lock.json primary_format: typst -kindle_name: typesec (0.13.0) -kindle_link: typesec (0.13.0).epub +kindle_name: typesec (0.13.1) +kindle_link: typesec (0.13.1).epub pdf_file: typesec.pdf epub_file: typesec.epub html_file: typesec.html html_chapters_dir: typesec-chapters html_title: Typesec -pdf_link: typesec (0.13.0-1926f18c).pdf -epub_link: typesec (0.13.0-1926f18c).epub -html_link: typesec (0.13.0-1926f18c).html -html_chapters_link: typesec (0.13.0-1926f18c)-chapters +pdf_link: typesec (0.13.1-a45ecd86).pdf +epub_link: typesec (0.13.1-a45ecd86).epub +html_link: typesec (0.13.1-a45ecd86).html +html_chapters_link: typesec (0.13.1-a45ecd86)-chapters mobi_file: typesec.mobi pdf_file_typst: typesec.pdf -pdf_link_typst: typesec (0.13.0-1926f18c).pdf +pdf_link_typst: typesec (0.13.1-a45ecd86).pdf diff --git a/docs/book/dist/typesec-chapters/assets/19461a35b786-diagram-2.png b/docs/book/dist/typesec-chapters/assets/19461a35b786-diagram-2.png new file mode 100644 index 0000000..5e1780a Binary files /dev/null and b/docs/book/dist/typesec-chapters/assets/19461a35b786-diagram-2.png differ diff --git a/docs/book/dist/typesec-chapters/assets/2c67e7d95538-diagram-1.png b/docs/book/dist/typesec-chapters/assets/2c67e7d95538-diagram-1.png new file mode 100644 index 0000000..46f1ad1 Binary files /dev/null and b/docs/book/dist/typesec-chapters/assets/2c67e7d95538-diagram-1.png differ diff --git a/docs/book/dist/typesec-chapters/assets/3d747fe2144a-diagram-4.png b/docs/book/dist/typesec-chapters/assets/3d747fe2144a-diagram-4.png deleted file mode 100644 index 1c3fed1..0000000 Binary files a/docs/book/dist/typesec-chapters/assets/3d747fe2144a-diagram-4.png and /dev/null differ diff --git a/docs/book/dist/typesec-chapters/assets/6b0bc060003a-diagram-6.png b/docs/book/dist/typesec-chapters/assets/6b0bc060003a-diagram-6.png new file mode 100644 index 0000000..ef84d7c Binary files /dev/null and b/docs/book/dist/typesec-chapters/assets/6b0bc060003a-diagram-6.png differ diff --git a/docs/book/dist/typesec-chapters/assets/6dc72828200f-diagram-5.png b/docs/book/dist/typesec-chapters/assets/6dc72828200f-diagram-5.png deleted file mode 100644 index 1be9c63..0000000 Binary files a/docs/book/dist/typesec-chapters/assets/6dc72828200f-diagram-5.png and /dev/null differ diff --git a/docs/book/dist/typesec-chapters/assets/7a086c098325-diagram-2.png b/docs/book/dist/typesec-chapters/assets/7a086c098325-diagram-2.png deleted file mode 100644 index 692e939..0000000 Binary files a/docs/book/dist/typesec-chapters/assets/7a086c098325-diagram-2.png and /dev/null differ diff --git a/docs/book/dist/typesec-chapters/assets/7f82fe1bb0ac-diagram-6.png b/docs/book/dist/typesec-chapters/assets/7f82fe1bb0ac-diagram-6.png deleted file mode 100644 index 51d6b0c..0000000 Binary files a/docs/book/dist/typesec-chapters/assets/7f82fe1bb0ac-diagram-6.png and /dev/null differ diff --git a/docs/book/dist/typesec-chapters/assets/9d1167336141-diagram-4.png b/docs/book/dist/typesec-chapters/assets/9d1167336141-diagram-4.png new file mode 100644 index 0000000..96ff32e Binary files /dev/null and b/docs/book/dist/typesec-chapters/assets/9d1167336141-diagram-4.png differ diff --git a/docs/book/dist/typesec-chapters/assets/a7f9654f1bae-diagram-3.png b/docs/book/dist/typesec-chapters/assets/a7f9654f1bae-diagram-3.png new file mode 100644 index 0000000..63b2f49 Binary files /dev/null and b/docs/book/dist/typesec-chapters/assets/a7f9654f1bae-diagram-3.png differ diff --git a/docs/book/dist/typesec-chapters/assets/c8edc2ccf59e-diagram-3.png b/docs/book/dist/typesec-chapters/assets/c8edc2ccf59e-diagram-3.png deleted file mode 100644 index 48f4cd1..0000000 Binary files a/docs/book/dist/typesec-chapters/assets/c8edc2ccf59e-diagram-3.png and /dev/null differ diff --git a/docs/book/dist/typesec-chapters/assets/d2676d91b2eb-diagram-1.png b/docs/book/dist/typesec-chapters/assets/d2676d91b2eb-diagram-1.png deleted file mode 100644 index 5995800..0000000 Binary files a/docs/book/dist/typesec-chapters/assets/d2676d91b2eb-diagram-1.png and /dev/null differ diff --git a/docs/book/dist/typesec-chapters/assets/d5ab1f8c38f7-diagram-5.png b/docs/book/dist/typesec-chapters/assets/d5ab1f8c38f7-diagram-5.png new file mode 100644 index 0000000..9744c8f Binary files /dev/null and b/docs/book/dist/typesec-chapters/assets/d5ab1f8c38f7-diagram-5.png differ diff --git a/docs/book/dist/typesec-chapters/chapter-001.html b/docs/book/dist/typesec-chapters/chapter-001.html index 98158e1..a6dc06b 100644 --- a/docs/book/dist/typesec-chapters/chapter-001.html +++ b/docs/book/dist/typesec-chapters/chapter-001.html @@ -5,7 +5,7 @@ - + Typesec