From ead01cb23d34511d4955fb786c59272e9447cfd1 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 17:53:09 +0800 Subject: [PATCH 1/8] test(memory): require governed served claim writes --- .../contextful-cli/tests/integration/serve.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/crates/contextful-cli/tests/integration/serve.rs b/crates/contextful-cli/tests/integration/serve.rs index e878f75d3..cb9be2bab 100644 --- a/crates/contextful-cli/tests/integration/serve.rs +++ b/crates/contextful-cli/tests/integration/serve.rs @@ -205,6 +205,67 @@ fn query() -> Value { json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "context.query", "arguments": { "sql": "SELECT note_id FROM \"research/notes\"" } } }) } +fn post_claim(addr: &str, token: &str, body: &Value, browser_origin: bool) -> (u16, Value) { + let body = body.to_string(); + let origin = if browser_origin { "Origin: https://console.example\r\n" } else { "" }; + let mut stream = TcpStream::connect(addr).unwrap(); + write!(stream, "POST /memory/claims HTTP/1.1\r\nAuthorization: Bearer {token}\r\n{origin}Content-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", body.len()).unwrap(); + let mut raw = String::new(); + stream.read_to_string(&mut raw).unwrap(); + let (head, data) = raw.split_once("\r\n\r\n").unwrap(); + (head.split(' ').nth(1).unwrap().parse().unwrap(), serde_json::from_str(data).unwrap_or(Value::Null)) +} + +#[test] +fn served_claim_write_is_durable_actor_bound_and_outside_read_mcp() { + let memory = "\n[[table]]\nname = \"memory/facts\"\nshape = \"memory_facts\"\ncolumns = [\"claim_id\", \"subject\", \"predicate\", \"object\", \"scope\", \"tier\", \"confidence\", \"valid_from\", \"valid_to\", \"evidence\", \"superseded_by\", \"grant_id\", \"agent\"]\n"; + let (dir, public) = project_declaring(memory); + let p = dir.path(); + let mint = |who: &str, task: &str, actions: &[&str]| { + let mut args = vec!["token", "mint", "--issuer-key", ".contextful/issuer.seed", "--on-behalf-of", who, + "--task", task, "--zone", "on-prem:hq", "--table", "memory/facts", "--ttl", "900"]; + for action in actions { args.extend(["--action", action]); } + stdout(&run(p, &args)) + }; + let alice = mint("user://alice", "session-1", &["read", "write"]); + let bob = mint("user://bob", "session-2", &["read", "write"]); + let reader = mint("user://alice", "session-1", &["read"]); + let claim = json!({ "into": "memory/facts", "actor": "user://alice", "session": "session-1", "dedup_key": "turn-1", + "claim": { "subject": "Northwind", "predicate": "filings", "object": "arrived", "confidence": 0.9, "evidence": [] } }); + let (listener, addr) = serve(p, &["serve", "--http", "127.0.0.1:0", "--audience", AUD, + "--max-in-flight", "2", "--project", "research", "--public-key", &public]); + assert_eq!(post_claim(&addr, &reader, &claim, false).0, 403, "read grant cannot write"); + assert_eq!(post_claim(&addr, &alice, &claim, true).0, 403, "a browser origin cannot reach the write"); + let (status, first) = post_claim(&addr, &alice, &claim, false); + assert_eq!(status, 200, "{first}"); + assert_eq!(first["landed"], true); + assert!(first["scope"].as_str().is_some_and(|scope| scope.contains("alice") && scope.contains("session-1")), "{first}"); + let (status, duplicate) = post_claim(&addr, &alice, &claim, false); + assert_eq!(status, 200, "{duplicate}"); + assert_eq!(duplicate["landed"], false); + let mut borrowed = claim.clone(); + borrowed["actor"] = json!("user://bob"); + assert_eq!(post_claim(&addr, &alice, &borrowed, false).0, 403, "a writer cannot author for another operator"); + borrowed["actor"] = json!("user://alice"); + borrowed["session"] = json!("session-2"); + assert_eq!(post_claim(&addr, &alice, &borrowed, false).0, 403, "a writer cannot pick another session"); + assert_eq!(post_claim(&addr, &bob, &claim, false).0, 403, "the second operator cannot author Alice's claim"); + let write_tool = json!({ "jsonrpc": "2.0", "id": 7, "method": "tools/call", + "params": { "name": "memory.write", "arguments": claim } }); + let (_, mcp) = post(&addr, &write_tool, &alice, None); + assert!(mcp.to_string().contains("no tool"), "{mcp}"); + drop(listener); + let (_restarted, addr) = serve(p, &["serve", "--http", "127.0.0.1:0", "--audience", AUD, + "--max-in-flight", "2", "--project", "research", "--public-key", &public]); + let recall = json!({ "jsonrpc": "2.0", "id": 8, "method": "tools/call", + "params": { "name": "memory.recall", "arguments": { "table": "memory/facts", "subject": "Northwind" } } }); + let (status, memory) = post(&addr, &recall, &reader, None); + assert_eq!(status, 200, "{memory}"); + assert_eq!(memory["result"]["structuredContent"]["rows"].as_array().map(Vec::len), Some(1), "{memory}"); + stdout(&run(p, &["token", "revoke", "--principal-class", "delegated"])); + assert_eq!(post_claim(&addr, &alice, &claim, false).0, 401, "revoked authority commits nothing"); +} + #[test] fn served_exchange_without_policy_returns_the_unconfigured_refusal() { let (dir, public) = project(); From f480c886c0b04540a330e41db196b2ff67d38ae8 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 18:27:52 +0800 Subject: [PATCH 2/8] test(memory): cover served claim admission and refusal --- .../tests/integration/http.rs | 31 +++++++++++++++++++ .../contextful-cli/tests/integration/serve.rs | 15 +++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/contextful-agent/tests/integration/http.rs b/crates/contextful-agent/tests/integration/http.rs index e23c3ed76..dc7234a71 100644 --- a/crates/contextful-agent/tests/integration/http.rs +++ b/crates/contextful-agent/tests/integration/http.rs @@ -253,6 +253,37 @@ fn stdio_answer(f: &Fixture, token: &str, message: &Value) -> Value { server.handle(&message.to_string()).unwrap() } +#[test] +fn server_held_claim_route_admits_each_request_and_keeps_browser_and_mcp_writes_out() { + let f: &'static Fixture = Box::leak(Box::new(fixture())); + let clock: &'static FixedClock = Box::leak(Box::new(FixedClock(at(NOW)))); + let admitting = Admitting { checkpoint: &f.checkpoint, audience: AUD, revocation: &no_revocation }; + let write = |_: &HttpRequest, authority: &AdmittedAuthority, boundary: &dyn Fn() -> Result<(), AuthorityError>| { + boundary().unwrap(); + HttpResponse::json(200, &json!({ "actor": authority.subject().on_behalf_of() })) + }; + let write: &'static _ = Box::leak(Box::new(write)); + let face = Box::leak(Box::new(HttpFace::new(&f.face, clock, &f.audit, admitting, Some(4)).unwrap().with_claim_write(write))); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || face.serve(listener)); + let token = credential(&f.signer, "research/*", 900, None); + let mut request = HttpRequest { method: "POST".into(), target: "/memory/claims".into(), + headers: vec![("Authorization".into(), format!("Bearer {token}"))], body: b"{}".to_vec() }; + let (status, _, response) = send(addr, &request); + assert_eq!(status, 200); + assert_eq!(body(&response)["actor"], "user://dana@acme.example"); + request.headers.push(("Origin".into(), "https://console.example".into())); + let (status, _, _) = send(addr, &request); + assert_eq!(status, 403); + request.headers.clear(); + let (status, _, _) = send(addr, &request); + assert_eq!(status, 401); + let (status, _, response) = send(addr, &unproven(&call("memory.write", json!({})), "Bearer", &token)); + assert_eq!(status, 200); + assert!(body(&response).to_string().contains("no tool")); +} + /// `contextful serve --http --audience --max-in-flight ` answers MCP Streamable HTTP at `POST /mcp`: one JSON-RPC message per request, answered as `application/json` by the tool server the stdio transport runs. // spec: read.register.network-transport@38ec986b #[test] diff --git a/crates/contextful-cli/tests/integration/serve.rs b/crates/contextful-cli/tests/integration/serve.rs index cb9be2bab..c1239b947 100644 --- a/crates/contextful-cli/tests/integration/serve.rs +++ b/crates/contextful-cli/tests/integration/serve.rs @@ -223,18 +223,29 @@ fn served_claim_write_is_durable_actor_bound_and_outside_read_mcp() { let p = dir.path(); let mint = |who: &str, task: &str, actions: &[&str]| { let mut args = vec!["token", "mint", "--issuer-key", ".contextful/issuer.seed", "--on-behalf-of", who, - "--task", task, "--zone", "on-prem:hq", "--table", "memory/facts", "--ttl", "900"]; + "--task", task, "--zone", "on-prem:hq", "--table", "memory/facts", "--table", "research/notes", "--ttl", "900"]; for action in actions { args.extend(["--action", action]); } stdout(&run(p, &args)) }; let alice = mint("user://alice", "session-1", &["read", "write"]); let bob = mint("user://bob", "session-2", &["read", "write"]); let reader = mint("user://alice", "session-1", &["read"]); + let wrong_table = stdout(&run(p, &["token", "mint", "--issuer-key", ".contextful/issuer.seed", "--on-behalf-of", "user://alice", + "--task", "session-1", "--zone", "on-prem:hq", "--action", "write", "--table", "research/notes", "--ttl", "900"])); + let expired = stdout(&run(p, &["token", "mint", "--issuer-key", ".contextful/issuer.seed", "--on-behalf-of", "user://alice", + "--task", "session-1", "--zone", "on-prem:hq", "--action", "read", "--action", "write", + "--table", "memory/facts", "--table", "research/notes", "--ttl", "900", "--now", "2020-01-01T00:00:00Z"])); let claim = json!({ "into": "memory/facts", "actor": "user://alice", "session": "session-1", "dedup_key": "turn-1", - "claim": { "subject": "Northwind", "predicate": "filings", "object": "arrived", "confidence": 0.9, "evidence": [] } }); + "claim": { "subject": "Northwind", "predicate": "filings", "object": "arrived", "confidence": 0.9, + "evidence": [{ "table": "research/notes", "run": "run-0001", "seq": 0 }] } }); let (listener, addr) = serve(p, &["serve", "--http", "127.0.0.1:0", "--audience", AUD, "--max-in-flight", "2", "--project", "research", "--public-key", &public]); assert_eq!(post_claim(&addr, &reader, &claim, false).0, 403, "read grant cannot write"); + assert_eq!(post_claim(&addr, &wrong_table, &claim, false).0, 403, "a write grant on another table cannot land memory"); + assert_eq!(post_claim(&addr, &expired, &claim, false).0, 401, "expired authority cannot write"); + let mut client_scope = claim.clone(); + client_scope["claim"]["scope"] = json!("shared"); + assert_eq!(post_claim(&addr, &alice, &client_scope, false).0, 400, "the caller cannot set claim scope"); assert_eq!(post_claim(&addr, &alice, &claim, true).0, 403, "a browser origin cannot reach the write"); let (status, first) = post_claim(&addr, &alice, &claim, false); assert_eq!(status, 200, "{first}"); From a4cd3073bcd526ab61206ffd88b6de582fbb3d77 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 18:29:23 +0800 Subject: [PATCH 3/8] feat(memory): serve scoped durable claim writes --- crates/contextful-agent/src/http.rs | 27 ++++++++++- crates/contextful-cli/src/serve.rs | 69 +++++++++++++++++++++++++++++ spec/21-memory.md | 13 +++++- spec/adr/A-read.md | 15 +++++++ spec/guide/read.md | 2 + spec/pins.toml | 5 +++ spec/terms/read.toml | 4 ++ 7 files changed, 133 insertions(+), 2 deletions(-) diff --git a/crates/contextful-agent/src/http.rs b/crates/contextful-agent/src/http.rs index 4ba956099..aac4c133c 100644 --- a/crates/contextful-agent/src/http.rs +++ b/crates/contextful-agent/src/http.rs @@ -203,6 +203,7 @@ pub struct HttpFace<'a, C> { in_flight: AtomicUsize, exchange: Option<&'a (dyn Fn(&HttpRequest) -> HttpResponse + Sync)>, exchange_unconfigured: bool, + claim_write: Option<&'a (dyn Fn(&HttpRequest, &AdmittedAuthority, &dyn Fn() -> Result<(), AuthorityError>) -> HttpResponse + Sync)>, } /// A request slot held while one request is in flight. @@ -249,7 +250,7 @@ impl<'a, C: Clock + Sync> HttpFace<'a, C> { let ceiling = ceiling(max_in_flight)?; audience(Some(admitting.audience))?; let tools = Tools::new(face, clock, record)?; - Ok(HttpFace { tools, admitting, control: None, ceiling, in_flight: AtomicUsize::new(0), exchange: None, exchange_unconfigured: false }) + Ok(HttpFace { tools, admitting, control: None, ceiling, in_flight: AtomicUsize::new(0), exchange: None, exchange_unconfigured: false, claim_write: None }) } /// The binary's exchange route mints the reader credential without putting issuer @@ -266,6 +267,12 @@ impl<'a, C: Clock + Sync> HttpFace<'a, C> { self } + /// The binary owns the claim writer; this route never joins the read MCP tool set. + pub fn with_claim_write(mut self, write: &'a (dyn Fn(&HttpRequest, &AdmittedAuthority, &dyn Fn() -> Result<(), AuthorityError>) -> HttpResponse + Sync)) -> Self { + self.claim_write = Some(write); + self + } + /// Accept connections on `listener` until it fails. Each accepted connection takes a /// slot before its thread starts and holds it until its answer is written, so at most /// the ceiling's count of connection threads run. One accepted with every slot held @@ -353,6 +360,8 @@ impl<'a, C: Clock + Sync> HttpFace<'a, C> { (MCP_PATH, _) => { HttpResponse::message(405, "`/mcp` answers POST; the face holds no session and opens no server stream").with("Allow", "POST") } + ("/memory/claims", "POST") if self.claim_write.is_some() => self.claim_write_request(request), + ("/memory/claims", _) if self.claim_write.is_some() => HttpResponse::message(405, "`/memory/claims` answers POST").with("Allow", "POST"), ("/auth/exchange", "POST") if self.exchange.is_some() => self.exchange.expect("checked above")(request), ("/auth/exchange", _) if self.exchange.is_some() => HttpResponse::message(405, "`/auth/exchange` answers POST").with("Allow", "POST"), (WORKFLOWS_PATH, "GET") | (APPLY_PATH, "POST") if self.control.is_some() => self.control(request), @@ -400,6 +409,22 @@ impl<'a, C: Clock + Sync> HttpFace<'a, C> { self.control.expect("the route is installed only with a control handler")(request) } + fn claim_write_request(&self, request: &HttpRequest) -> HttpResponse { + if request.header("Origin").is_some() { + return HttpResponse::json(403, &json!({ "error": { "http": 403, "identifier": "MemoryClaimBrowserRefused" } })); + } + let (authority, _) = match self.admit(request) { + Ok(a) => a, + Err(response) => return response, + }; + let boundary = || { + let revocation = (self.admitting.revocation)().map_err(AuthorityError::AuthorityRevoked)?; + let admission = Admission::new(self.tools.clock().now(), &revocation).expecting(self.admitting.audience); + effect_boundary(&authority, &admission) + }; + self.claim_write.expect("route exists")(request, &authority, &boundary) + } + fn admit(&self, request: &HttpRequest) -> Result<(AdmittedAuthority, RevocationState), HttpResponse> { let Some(credential) = request.credential() else { let missing = ReadError::HttpCredentialMissing( diff --git a/crates/contextful-cli/src/serve.rs b/crates/contextful-cli/src/serve.rs index 2234b8cc6..afe90841e 100644 --- a/crates/contextful-cli/src/serve.rs +++ b/crates/contextful-cli/src/serve.rs @@ -17,6 +17,12 @@ use anyhow::Result; use contextful_agent::http::{audience, ceiling, Admitting, HttpFace}; use contextful_core::issue::{IssuancePolicy, MintContext, NodeRole}; use contextful_core::ports::{Clock, SigningPort}; +#[cfg(feature = "data-plane")] +use contextful_core::memory::synthesize::CandidateClaim; +#[cfg(feature = "data-plane")] +use contextful_memory::write::{write_observed, Observation}; +#[cfg(feature = "data-plane")] +use contextful_memory::MemoryFault; use contextful_policy::exchange::answer as exchange_answer; use contextful_policy::issue::{SeedSigner, DEFAULT_SEED_PATH}; use contextful_policy::possession::ProofChecker; @@ -34,6 +40,31 @@ use std::sync::Arc; #[cfg(feature = "data-plane")] use serde_json::{json, Value}; +#[cfg(feature = "data-plane")] +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct ClaimWriteRequest { + into: String, + actor: String, + session: String, + dedup_key: String, + claim: CandidateClaim, +} + +#[cfg(feature = "data-plane")] +fn claim_write_error(error: MemoryFault) -> contextful_agent::http::HttpResponse { + let (status, identifier): (u16, String) = match &error { + MemoryFault::Denied(_) => (403, "GrantWriteNotCovered".into()), + MemoryFault::Authority(why) => (401, why.to_string().split(':').next().unwrap_or("AuthorityRevoked").to_owned()), + MemoryFault::Invalid(_) => (400, "MemoryClaimMalformed".into()), + MemoryFault::Memory(why) => (400, why.identifier().into()), + _ => (503, "MemoryClaimWriteRefused".into()), + }; + contextful_agent::http::HttpResponse::json(status, &serde_json::json!({ + "error": { "http": status, "identifier": identifier, "message": error.to_string() } + })) +} + /// The refusals of starting the network transport. `Display` begins with the identifier. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum ServeError { @@ -134,6 +165,42 @@ pub fn run(args: ServeArgs, tasks: &Tasks) -> Result<()> { None }; let proofs = ProofChecker::new(SystemClock); + #[cfg(feature = "data-plane")] + let (node, _) = contextful_context::node::resolve(face.store(), |k| std::env::var(k).ok())?; + #[cfg(feature = "data-plane")] + let claim_write = |request: &contextful_agent::http::HttpRequest, + authority: &contextful_policy::verify::AdmittedAuthority, + boundary: &dyn Fn() -> Result<(), contextful_core::AuthorityError>| { + let body: ClaimWriteRequest = match serde_json::from_slice(&request.body) { + Ok(body) => body, + Err(_) => return contextful_agent::http::HttpResponse::json(400, &serde_json::json!({ + "error": { "http": 400, "identifier": "MemoryClaimMalformed" } + })), + }; + if body.actor.is_empty() || body.session.is_empty() || + authority.subject().on_behalf_of() != Some(body.actor.as_str()) || + authority.subject().task() != Some(body.session.as_str()) { + return contextful_agent::http::HttpResponse::json(403, &serde_json::json!({ + "error": { "http": 403, "identifier": "MemoryClaimScopeRefused" } + })); + } + let mut claim = body.claim; + if claim.scope.is_some() || body.dedup_key.trim().is_empty() { + return contextful_agent::http::HttpResponse::json(400, &serde_json::json!({ + "error": { "http": 400, "identifier": "MemoryClaimMalformed" } + })); + } + claim.scope = Some(format!("console:{}:{}", body.actor, body.session)); + let scope = claim.scope.clone().expect("assigned"); + match write_observed(&face, authority, &body.into, claim, + &Observation { observed_at: None, dedup_key: Some(body.dedup_key) }, &node, clock.now(), boundary) { + Ok(written) => contextful_agent::http::HttpResponse::json(200, &serde_json::json!({ + "landed": written.claim.is_some(), "scope": scope, + "claim_id": written.claim.map(|claim| claim.claim_id) + })), + Err(error) => claim_write_error(error), + } + }; let exchange_route = |request: &contextful_agent::http::HttpRequest| { let Some((issuance, signer)) = mint_material.as_ref() else { return contextful_agent::http::HttpResponse::json(404, @@ -180,6 +247,8 @@ pub fn run(args: ServeArgs, tasks: &Tasks) -> Result<()> { .with_exchange(&exchange_route, exchange.is_none()); #[cfg(feature = "data-plane")] let http = http.with_control(&control); + #[cfg(feature = "data-plane")] + let http = http.with_claim_write(&claim_write); let listener = TcpListener::bind(&args.http)?; eprintln!("listening on http://{}/mcp", listener.local_addr()?); http.serve(listener)?; diff --git a/spec/21-memory.md b/spec/21-memory.md index 0ab9aa958..a787d5902 100644 --- a/spec/21-memory.md +++ b/spec/21-memory.md @@ -119,7 +119,18 @@ Supersession within one validity line, confidence decay, the direct write and it *A-read* - `retention-default` — Claims retain their recorded validity until explicit expiry or erasure; ranking decay is opt-in per shape with a declared half-life, and never deletes a claim or changes its validity interval. *A-read* - +- `served-write` — A writable served face answers `POST /memory/claims` under a per-request network credential, lands a declared claim through the direct write, and keeps the read MCP tool set closed. + *A-read* +- `served-scope` — A claim write whose `actor` or `session` differs from the admitted credential's `on_behalf_of` or `task` raises `MemoryClaimScopeRefused`; accepted claims take scope from those members, never from the payload. + *A-read* +- `browser-request` — An `Origin` header on the served claim-write route raises `MemoryClaimBrowserRefused` before credential admission and commits nothing. + *A-read* +- `malformed-request` — Malformed JSON, unknown fields, a client-selected claim scope, or an empty dedup key on the served claim-write route raises `MemoryClaimMalformed` and commits nothing. + *A-read* +- `served-dedup` — A served claim write passes its `dedup_key` to {{read.revise.dedup-key}}, returns `landed: false` on restatement, and returns the bound scope on both outcomes. + *A-read* +- `served-fault` — A served claim write whose store effect refuses raises `MemoryClaimWriteRefused` and lands nothing. + *A-read* ## recall diff --git a/spec/adr/A-read.md b/spec/adr/A-read.md index 66e9482bf..92e4a2396 100644 --- a/spec/adr/A-read.md +++ b/spec/adr/A-read.md @@ -85,6 +85,21 @@ Criteria: MCP-client compatibility, then one surface per guarantee, then revocat Consequences: admission sits on every read, and a possession-proof check sits on every read under a holder-bound credential; an MCP client that signs nothing reads with a short-lived audience-bound bearer and re-exchanges it before expiry. Revisit: a caller needs server-initiated messages or resumable streams. +## A served claim write stays outside read MCP + +Context: an operator answer distils claims after its read turn, while a hosted console owns no local store root. Criteria: preserve the closed read tool set, the writer's actor and session, and the memory table's commit and evidence gates. + +Decision: a separate served route admits the server-held credential, binds the actor and session to its subject tuple, and calls the direct memory write. The credential needs a narrow write grant on the declared claims table. The browser holds no writer credential; an Origin header refuses. The read-only edge profile mounts no write route. + +| Option | Lost on | Cost | +| --- | --- | --- | +| Separate route and direct memory write *(chosen)* | — | The hosted console needs a writing credential whose task names its reading session. | +| Add `memory.write` to read MCP | Closed read tool set | A client-reachable tool can mutate a store. | +| Spawn the CLI from the console | Hosted reach | The console needs the store's local project root and signing configuration. | +| Keep claims in the console process | Durability | Restart loses conclusions and bypasses memory recall and erasure. | + +Consequences: claim writes share memory's evidence, supersession, attribution and commit path. A credential with only read grants cannot land a claim. + ## Memory writes validate or dead-letter, and outcomes settle under their source `read.synthesize` validates every candidate against the declared output schema, retries with the error up to 3 attempts per batch, then dead-letters the response, template hash and drop reason with the cursor held. The relation vocabulary is a reserved core plus declared types; an undeclared edge dead-letters while the batch lands. `read.resolve-entity` dead-letters ambiguous mentions and dangling endpoints. `read.settle` requires one resolution form and one source — `metric`, `adjudicator` or `manual`; metric comparators evaluate outside the engine, verdicts carry an `http`/`https` citation, self-rated outcomes carry a null verdict, and the scored and unresolved views partition the join. diff --git a/spec/guide/read.md b/spec/guide/read.md index 2acd023e8..46b429a0e 100644 --- a/spec/guide/read.md +++ b/spec/guide/read.md @@ -67,6 +67,8 @@ Memory uses ordinary store tables ({{topology.compose.memory-substrate}}). Synth Memory's relation vocabulary, cadence and confidence follow {{read.declare.reserved-relations}}, {{read.synthesize.cadence}} and {{read.synthesize.confidence-calibration}}. Recall usage follows {{read.recall.usage-ledger}}. +Operator claims land through {{read.revise.served-write}} under credential-bound scope ({{read.revise.served-scope}}); browser requests refuse ({{read.revise.browser-request}}). + ```mermaid sequenceDiagram box run diff --git a/spec/pins.toml b/spec/pins.toml index 4604d0c7b..ca92d4efa 100644 --- a/spec/pins.toml +++ b/spec/pins.toml @@ -63,6 +63,11 @@ "run.export.typed-watch" = { test = "crates/contextful-cli/tests/integration/export.rs::typed_export_watch_retries_a_refused_publication" } "run.export.typed-byte-limit" = { test = "crates/contextful-cli/tests/integration/export.rs::typed_export_splits_requests_by_bytes_and_refuses_an_oversized_event" } "run.export.typed-event-too-large" = { test = "crates/contextful-cli/tests/integration/export.rs::typed_export_splits_requests_by_bytes_and_refuses_an_oversized_event" } +"read.revise.served-write" = { test = "contextful_cli::serve::served_claim_write_is_durable_actor_bound_and_outside_read_mcp" } +"read.revise.served-scope" = { test = "contextful_cli::serve::served_claim_write_is_durable_actor_bound_and_outside_read_mcp" } +"read.revise.browser-request" = { test = "contextful_cli::serve::served_claim_write_is_durable_actor_bound_and_outside_read_mcp" } +"read.revise.malformed-request" = { test = "contextful_cli::serve::served_claim_write_is_durable_actor_bound_and_outside_read_mcp" } +"read.revise.served-dedup" = { test = "contextful_cli::serve::served_claim_write_is_durable_actor_bound_and_outside_read_mcp" } "corpus.state.typescript-tag-pin" = { test = "contextful_spec::ts_pins::a_running_surface_test_tag_performs_its_clause" } "corpus.state.typescript-unfinished" = { test = "contextful_spec::ts_pins::a_skipped_or_todo_surface_test_is_broken" } "assurance.gate.native-test-files" = { test = "contextful_ci::stages::the_surfaces_stage_runs_each_native_typescript_test_file_even_without_a_test_script" } diff --git a/spec/terms/read.toml b/spec/terms/read.toml index b6aaa22c6..8d387250b 100644 --- a/spec/terms/read.toml +++ b/spec/terms/read.toml @@ -40,6 +40,10 @@ MemoryUndeclaredRelation = { clause = "read.declare.undeclared-relation", g MemoryExtractExhausted = { clause = "read.synthesize.dead-letter", gloss = "An extract batch exhausted its attempts and was dead-lettered." } MemoryDirectWriteShapeRefused = { clause = "read.revise.direct-write", gloss = "A direct write targeted a memory shape other than claims." } MemoryCitationNotLive = { clause = "read.revise.citation-live", gloss = "A direct write cites a keyed row that is not its key's live version." } +MemoryClaimScopeRefused = { clause = "read.revise.served-scope", gloss = "A served claim write names another actor or reading session." } +MemoryClaimBrowserRefused = { clause = "read.revise.browser-request", gloss = "A browser-origin request reached the served claim-write route." } +MemoryClaimMalformed = { clause = "read.revise.malformed-request", gloss = "A served claim write holds a malformed or client-scoped body." } +MemoryClaimWriteRefused = { clause = "read.revise.served-fault", gloss = "A served claim write's store effect refused." } MemoryEvidenceUnresolved = { clause = "read.recall.evidence-unresolved", gloss = "A claim's evidence does not resolve through the caller's session." } MemoryEvidenceOverflow = { clause = "read.recall.evidence-references", gloss = "A claim names more evidence references than the bound." } MemoryObservationOutOfOrder = { clause = "read.revise.observed-order", gloss = "A claim was observed before a live contradicting claim of its line." } From 3fe8c179aa21c425f004ad774c9a5833033f4982 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Wed, 7 Oct 2026 00:24:33 +0800 Subject: [PATCH 4/8] Regenerate console memory corpus Test-First: refactor --- spec/cards/read.md | 6 +- spec/spec.lock.json | 144 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 120 insertions(+), 30 deletions(-) diff --git a/spec/cards/read.md b/spec/cards/read.md index 3ec1a6f61..4d1c53e07 100644 --- a/spec/cards/read.md +++ b/spec/cards/read.md @@ -17,7 +17,7 @@ Generated by `contextful-spec state` from the contract's files and `spec/terms/r | `embed` | The embedding port and its default, the model identifier beside each vector, the linked-backend identity a handshake reports, and refusals on an absent backend. | 6 | | `declare` | The five memory table shapes, their canonical columns, tier and scope, and the relation vocabulary with its cardinality. | 3 | | `synthesize` | Extract, Resolve and Consolidate; the dedup key, evidence support, the audit record and coverage gaps. | 9 | -| `revise` | Supersession within one validity line, confidence decay, the direct write and its anchor, expiry, retention, promotion. | 9 | +| `revise` | Supersession within one validity line, confidence decay, the direct write and its anchor, expiry, retention, promotion. | 15 | | `recall` | Serving memory: the ranked arm at the read's anchor, the keyed read at an observed instant, the two clocks, tier-first ordering, the scope filter, the tombstone filter, the evidence gate. | 18 | | `resolve-entity` | Mention-to-entity matching, aliases, the knowledge card, place identity and nearness, edges, and ownership answers. | 4 | | `settle` | Predictions, observations, the registration, the citation a verdict owes, and the label views. | 5 | @@ -49,6 +49,10 @@ Generated by `contextful-spec state` from the contract's files and `spec/terms/r | `MemoryDirectWriteShapeRefused` | A direct write targeted a memory shape other than claims. | `read.revise.direct-write` | | `MemoryObservationOutOfOrder` | A claim was observed before a live contradicting claim of its line. | `read.revise.observed-order` | | `MemoryCitationNotLive` | A direct write cites a keyed row that is not its key's live version. | `read.revise.citation-live` | +| `MemoryClaimScopeRefused` | A served claim write names another actor or reading session. | `read.revise.served-scope` | +| `MemoryClaimBrowserRefused` | A browser-origin request reached the served claim-write route. | `read.revise.browser-request` | +| `MemoryClaimMalformed` | A served claim write holds a malformed or client-scoped body. | `read.revise.malformed-request` | +| `MemoryClaimWriteRefused` | A served claim write's store effect refused. | `read.revise.served-fault` | | `MemoryEvidenceUnresolved` | A claim's evidence does not resolve through the caller's session. | `read.recall.evidence-unresolved` | | `MemoryEvidenceOverflow` | A claim names more evidence references than the bound. | `read.recall.evidence-references` | | `MemoryRecallNotClaims` | A keyed recall named a table declaring no `memory_facts` shape. | `read.recall.keyed-not-claims` | diff --git a/spec/spec.lock.json b/spec/spec.lock.json index 57e63f542..cbdd88ec3 100644 --- a/spec/spec.lock.json +++ b/spec/spec.lock.json @@ -6313,6 +6313,72 @@ "file": "spec/21-memory.md", "line": 116 }, + { + "id": "read.revise.served-write", + "contract": "read", + "operation": "revise", + "subject": "served-write", + "kind": "behavior", + "statement": "A writable served face answers `POST /memory/claims` under a per-request network credential, lands a declared claim through the direct write, and keeps the read MCP tool set closed.", + "why": "A-read", + "file": "spec/21-memory.md", + "line": 118 + }, + { + "id": "read.revise.served-scope", + "contract": "read", + "operation": "revise", + "subject": "served-scope", + "kind": "refusal", + "statement": "A claim write whose `actor` or `session` differs from the admitted credential's `on_behalf_of` or `task` raises `MemoryClaimScopeRefused`; accepted claims take scope from those members, never from the payload.", + "why": "A-read", + "file": "spec/21-memory.md", + "line": 120 + }, + { + "id": "read.revise.browser-request", + "contract": "read", + "operation": "revise", + "subject": "browser-request", + "kind": "refusal", + "statement": "An `Origin` header on the served claim-write route raises `MemoryClaimBrowserRefused` before credential admission and commits nothing.", + "why": "A-read", + "file": "spec/21-memory.md", + "line": 122 + }, + { + "id": "read.revise.malformed-request", + "contract": "read", + "operation": "revise", + "subject": "malformed-request", + "kind": "refusal", + "statement": "Malformed JSON, unknown fields, a client-selected claim scope, or an empty dedup key on the served claim-write route raises `MemoryClaimMalformed` and commits nothing.", + "why": "A-read", + "file": "spec/21-memory.md", + "line": 124 + }, + { + "id": "read.revise.served-dedup", + "contract": "read", + "operation": "revise", + "subject": "served-dedup", + "kind": "behavior", + "statement": "A served claim write passes its `dedup_key` to {{read.revise.dedup-key}}, returns `landed: false` on restatement, and returns the bound scope on both outcomes.", + "why": "A-read", + "file": "spec/21-memory.md", + "line": 126 + }, + { + "id": "read.revise.served-fault", + "contract": "read", + "operation": "revise", + "subject": "served-fault", + "kind": "refusal", + "statement": "A served claim write whose store effect refuses raises `MemoryClaimWriteRefused` and lands nothing.", + "why": "A-read", + "file": "spec/21-memory.md", + "line": 128 + }, { "id": "read.revise.unscoped-collision", "contract": "read", @@ -6322,7 +6388,7 @@ "statement": "Direct writes with no scope, equal subject, predicate and valid-from, and different objects record both claim ids in the dead-letter table; neither claim retires the other until an explicit revision resolves the conflict.", "why": "A-read", "file": "spec/21-memory.md", - "line": 118 + "line": 130 }, { "id": "read.revise.retention-default", @@ -6333,7 +6399,7 @@ "statement": "Claims retain their recorded validity until explicit expiry or erasure; ranking decay is opt-in per shape with a declared half-life, and never deletes a claim or changes its validity interval.", "why": "A-read", "file": "spec/21-memory.md", - "line": 120 + "line": 132 }, { "id": "read.recall.evidence-unresolved", @@ -6344,7 +6410,7 @@ "statement": "An unreadable or masked source row, an unknown table, a reference into another memory row, or malformed lineage suppresses the claim and raises `MemoryEvidenceUnresolved`.", "why": "A-read", "file": "spec/21-memory.md", - "line": 128 + "line": 140 }, { "id": "read.recall.evidence-references", @@ -6355,7 +6421,7 @@ "statement": "A claim naming more than 256 entries of evidence is suppressed unresolved, raising `MemoryEvidenceOverflow`.", "why": "A-read", "file": "spec/21-memory.md", - "line": 130 + "line": 142 }, { "id": "read.recall.evidence-key", @@ -6366,7 +6432,7 @@ "statement": "The write landing a claim stamps each citation into a keyed table with a pepper-keyed digest of the cited row's key; recall resolves a citation whose row no longer reads through the digest's live version.", "why": "A-read", "file": "spec/21-memory.md", - "line": 132 + "line": 144 }, { "id": "read.recall.evidence-key-masked", @@ -6377,7 +6443,7 @@ "statement": "A citation whose key column reads masked or nulled by zone in the writer's session carries no digest; a masked or nulled column outside the key leaves the digest stamped.", "why": "because a masked key cell digests a value no other session reads, while a cell outside the key plays no part in matching it", "file": "spec/21-memory.md", - "line": 134 + "line": 146 }, { "id": "read.recall.evidence-no-value", @@ -6388,7 +6454,7 @@ "statement": "A citation stored in a memory row carries the cited table, run, sequence and key digest, and no cell value of the cited row.", "why": "A-read", "file": "spec/21-memory.md", - "line": 136 + "line": 148 }, { "id": "read.recall.evidence-stale", @@ -6399,7 +6465,7 @@ "statement": "A claim served through its key digest while its cited run and sequence no longer read counts under `stale` in the `contextful.recall` block, which names no claim.", "why": "A-read", "file": "spec/21-memory.md", - "line": 138 + "line": 150 }, { "id": "read.recall.ranked-arm", @@ -6410,7 +6476,7 @@ "statement": "A `corpus.retrieve` arm over a `memory_facts` table serves only live claims — no `superseded_by`, and a `valid_to` null or past the read's anchor — whose evidence passes the gate.", "why": "", "file": "spec/21-memory.md", - "line": 140 + "line": 152 }, { "id": "read.recall.suppression-count", @@ -6421,7 +6487,7 @@ "statement": "A suppressed claim is absent from the rows; the `contextful.recall` block counts suppressions per error identifier and names no claim.", "why": "because a count discloses that a conclusion was withheld, never what it concluded", "file": "spec/21-memory.md", - "line": 141 + "line": 153 }, { "id": "read.recall.keyed", @@ -6432,7 +6498,7 @@ "statement": "`memory.recall` takes `table`, `subject`, `observed_at`, `as_of_ingest` and `limit`, and returns the claims whose `subject` equals `subject` exactly and whose `valid_from` and `valid_to` cover `observed_at` as {{store.bound-time.valid-as-of}} does.", "why": "", "file": "spec/21-memory.md", - "line": 143 + "line": 155 }, { "id": "read.recall.cli-verb", @@ -6443,7 +6509,7 @@ "statement": "The `contextful memory recall` verb passes its arguments through {{read.recall.keyed}} and prints the same JSON response as the `memory.recall` tool.", "why": "", "file": "spec/21-memory.md", - "line": 144 + "line": 156 }, { "id": "read.recall.keyed-clocks", @@ -6454,7 +6520,7 @@ "statement": "`as_of_ingest` bounds the read as {{store.bound-time.as-of}} does; absent, the read takes the latest committed state, and an absent `observed_at` the call's instant. `contextful.bounds` echoes each supplied bound under its argument name, with a per-name `inclusive` map.", "why": "", "file": "spec/21-memory.md", - "line": 145 + "line": 157 }, { "id": "read.recall.keyed-history", @@ -6465,7 +6531,7 @@ "statement": "A claim a successor retired still answers an `observed_at` inside its validity; the keyed read filters on validity alone, never on `superseded_by`.", "why": "because the question is what held at that instant, and the retired claim is the answer its interval records", "file": "spec/21-memory.md", - "line": 146 + "line": 158 }, { "id": "read.recall.keyed-gate", @@ -6476,7 +6542,7 @@ "statement": "Every keyed claim passes {{read.recall.evidence-unresolved}} and {{read.recall.evidence-references}}, and the response carries {{read.recall.suppression-count}}.", "why": "", "file": "spec/21-memory.md", - "line": 148 + "line": 160 }, { "id": "read.recall.keyed-window", @@ -6487,7 +6553,7 @@ "statement": "The keyed read gates claims in order and stops once it keeps one claim past the row ceiling, so the suppression count covers the claims gated before that stop.", "why": "because a one-row recall over a subject with thousands of claims otherwise resolves every claim's evidence", "file": "spec/21-memory.md", - "line": 149 + "line": 161 }, { "id": "read.recall.keyed-order", @@ -6498,7 +6564,7 @@ "statement": "Keyed claims order by tier, `curated` first, then `valid_from`, newest first, then `claim_id`; `limit` and the table's ceilings bound them under {{read.respond.row-ceiling}}.", "why": "", "file": "spec/21-memory.md", - "line": 151 + "line": 163 }, { "id": "read.recall.keyed-not-claims", @@ -6509,7 +6575,7 @@ "statement": "A granted `table` declaring a shape other than `memory_facts`, or no memory shape, raises `MemoryRecallNotClaims`.", "why": "because a keyed read answers with a claim's subject, validity and evidence, which no other table carries", "file": "spec/21-memory.md", - "line": 152 + "line": 164 }, { "id": "read.recall.stale-revision", @@ -6520,7 +6586,7 @@ "statement": "A stale citation remains subject to {{read.recall.keyed-gate}}; only a newly committed source row advancing {{read.synthesize.pass-cursor}} starts synthesis against its live key version.", "why": "A-read", "file": "spec/21-memory.md", - "line": 154 + "line": 166 }, { "id": "read.recall.usage-ledger", @@ -6531,7 +6597,7 @@ "statement": "Each `memory.recall` and ranked memory arm records the ids of claims actually returned in the request ledger, under the caller's admitted authority and the read frontier.", "why": "A-read", "file": "spec/21-memory.md", - "line": 156 + "line": 168 }, { "id": "read.resolve-entity.ambiguous-mention", @@ -6542,7 +6608,7 @@ "statement": "A mention matching two canonical identities with no deterministic key separating them is recorded as an ambiguous skip, raises `MemoryEntityAmbiguous`, and dead-letters its candidate claim.", "why": "A-read", "file": "spec/21-memory.md", - "line": 174 + "line": 186 }, { "id": "read.resolve-entity.edge-endpoint", @@ -6553,7 +6619,7 @@ "statement": "A candidate edge whose source or target resolves to no identity is dead-lettered, raising `MemoryEdgeEndpointUnresolved`.", "why": "A-read", "file": "spec/21-memory.md", - "line": 176 + "line": 188 }, { "id": "read.resolve-entity.graph-index", @@ -6564,7 +6630,7 @@ "statement": "An external graph engine is a derived index of admitted memory edges; an entity or ownership answer resolves through the store's enforced rows at the read frontier.", "why": "A-read", "file": "spec/21-memory.md", - "line": 178 + "line": 190 }, { "id": "read.resolve-entity.ownership-answer", @@ -6575,7 +6641,7 @@ "statement": "An artifact's ownership answer returns every attached principal visible to the caller, newest attachment first, with principal id breaking equal-instant ties.", "why": "A-read", "file": "spec/21-memory.md", - "line": 180 + "line": 192 }, { "id": "read.settle.registration", @@ -6586,7 +6652,7 @@ "statement": "A registration names exactly one form (relative horizon, absolute deadline, open watch) and one source (`metric`, `adjudicator`, `manual`), with a comparator exactly when the source is `metric`; otherwise it raises `OutcomeRegistrationInvalid`.", "why": "A-read", "file": "spec/21-memory.md", - "line": 188 + "line": 200 }, { "id": "read.settle.source-mismatch", @@ -6597,7 +6663,7 @@ "statement": "An observation carrying a verdict whose resolution source is absent or differs from the registration's raises `OutcomeSourceMismatch`.", "why": "A-read", "file": "spec/21-memory.md", - "line": 190 + "line": 202 }, { "id": "read.settle.settling-citation", @@ -6608,7 +6674,7 @@ "statement": "An `adjudicator` or `manual` verdict without an `http` or `https` settling citation raises `OutcomeCitationMissing`; the citation rides the label view.", "why": "A-read", "file": "spec/21-memory.md", - "line": 192 + "line": 204 }, { "id": "read.settle.grace-window", @@ -6619,7 +6685,7 @@ "statement": "The label join keeps an observation from the prediction instant through the deadline plus an inclusive grace of 86400 s.", "why": "", "file": "spec/21-memory.md", - "line": 194 + "line": 206 }, { "id": "read.settle.calibration-report", @@ -6630,7 +6696,7 @@ "statement": "Calibration reports group settled predictions by shape and predicate, and name sample count, Brier score, expected calibration error and the held-out score used by {{read.synthesize.confidence-calibration}}.", "why": "A-read", "file": "spec/21-memory.md", - "line": 195 + "line": 207 }, { "id": "run.journal.step-output", @@ -24437,6 +24503,10 @@ "read.revise.supersede", "read.revise.unscoped-collision" ], + [ + "read.revise.served-dedup", + "read.revise.dedup-key" + ], [ "read.recall.keyed", "store.bound-time.valid-as-of" @@ -28297,6 +28367,22 @@ "clause": "read.revise.citation-live", "gloss": "A direct write cites a keyed row that is not its key's live version." }, + "MemoryClaimBrowserRefused": { + "clause": "read.revise.browser-request", + "gloss": "A browser-origin request reached the served claim-write route." + }, + "MemoryClaimMalformed": { + "clause": "read.revise.malformed-request", + "gloss": "A served claim write holds a malformed or client-scoped body." + }, + "MemoryClaimScopeRefused": { + "clause": "read.revise.served-scope", + "gloss": "A served claim write names another actor or reading session." + }, + "MemoryClaimWriteRefused": { + "clause": "read.revise.served-fault", + "gloss": "A served claim write's store effect refused." + }, "MemoryDirectWriteShapeRefused": { "clause": "read.revise.direct-write", "gloss": "A direct write targeted a memory shape other than claims." From 0b165aa484a83b068c4811373ac9377009955abf Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Fri, 9 Oct 2026 01:40:22 +0800 Subject: [PATCH 5/8] Regenerate spec state and raise coverage floors --- spec/pins.toml | 2 +- spec/spec.lock.json | 108 ++++++++++++++++++++++---------------------- 2 files changed, 55 insertions(+), 55 deletions(-) diff --git a/spec/pins.toml b/spec/pins.toml index ca92d4efa..ebae12a86 100644 --- a/spec/pins.toml +++ b/spec/pins.toml @@ -101,7 +101,7 @@ authority = 207 connector = 229 corpus = 43 disclosure = 63 -read = 178 +read = 183 run = 306 store = 216 surface = 72 diff --git a/spec/spec.lock.json b/spec/spec.lock.json index cbdd88ec3..e9f9fe319 100644 --- a/spec/spec.lock.json +++ b/spec/spec.lock.json @@ -6313,6 +6313,28 @@ "file": "spec/21-memory.md", "line": 116 }, + { + "id": "read.revise.unscoped-collision", + "contract": "read", + "operation": "revise", + "subject": "unscoped-collision", + "kind": "behavior", + "statement": "Direct writes with no scope, equal subject, predicate and valid-from, and different objects record both claim ids in the dead-letter table; neither claim retires the other until an explicit revision resolves the conflict.", + "why": "A-read", + "file": "spec/21-memory.md", + "line": 118 + }, + { + "id": "read.revise.retention-default", + "contract": "read", + "operation": "revise", + "subject": "retention-default", + "kind": "behavior", + "statement": "Claims retain their recorded validity until explicit expiry or erasure; ranking decay is opt-in per shape with a declared half-life, and never deletes a claim or changes its validity interval.", + "why": "A-read", + "file": "spec/21-memory.md", + "line": 120 + }, { "id": "read.revise.served-write", "contract": "read", @@ -6322,7 +6344,7 @@ "statement": "A writable served face answers `POST /memory/claims` under a per-request network credential, lands a declared claim through the direct write, and keeps the read MCP tool set closed.", "why": "A-read", "file": "spec/21-memory.md", - "line": 118 + "line": 122 }, { "id": "read.revise.served-scope", @@ -6333,7 +6355,7 @@ "statement": "A claim write whose `actor` or `session` differs from the admitted credential's `on_behalf_of` or `task` raises `MemoryClaimScopeRefused`; accepted claims take scope from those members, never from the payload.", "why": "A-read", "file": "spec/21-memory.md", - "line": 120 + "line": 124 }, { "id": "read.revise.browser-request", @@ -6344,7 +6366,7 @@ "statement": "An `Origin` header on the served claim-write route raises `MemoryClaimBrowserRefused` before credential admission and commits nothing.", "why": "A-read", "file": "spec/21-memory.md", - "line": 122 + "line": 126 }, { "id": "read.revise.malformed-request", @@ -6355,7 +6377,7 @@ "statement": "Malformed JSON, unknown fields, a client-selected claim scope, or an empty dedup key on the served claim-write route raises `MemoryClaimMalformed` and commits nothing.", "why": "A-read", "file": "spec/21-memory.md", - "line": 124 + "line": 128 }, { "id": "read.revise.served-dedup", @@ -6366,7 +6388,7 @@ "statement": "A served claim write passes its `dedup_key` to {{read.revise.dedup-key}}, returns `landed: false` on restatement, and returns the bound scope on both outcomes.", "why": "A-read", "file": "spec/21-memory.md", - "line": 126 + "line": 130 }, { "id": "read.revise.served-fault", @@ -6377,28 +6399,6 @@ "statement": "A served claim write whose store effect refuses raises `MemoryClaimWriteRefused` and lands nothing.", "why": "A-read", "file": "spec/21-memory.md", - "line": 128 - }, - { - "id": "read.revise.unscoped-collision", - "contract": "read", - "operation": "revise", - "subject": "unscoped-collision", - "kind": "behavior", - "statement": "Direct writes with no scope, equal subject, predicate and valid-from, and different objects record both claim ids in the dead-letter table; neither claim retires the other until an explicit revision resolves the conflict.", - "why": "A-read", - "file": "spec/21-memory.md", - "line": 130 - }, - { - "id": "read.revise.retention-default", - "contract": "read", - "operation": "revise", - "subject": "retention-default", - "kind": "behavior", - "statement": "Claims retain their recorded validity until explicit expiry or erasure; ranking decay is opt-in per shape with a declared half-life, and never deletes a claim or changes its validity interval.", - "why": "A-read", - "file": "spec/21-memory.md", "line": 132 }, { @@ -6410,7 +6410,7 @@ "statement": "An unreadable or masked source row, an unknown table, a reference into another memory row, or malformed lineage suppresses the claim and raises `MemoryEvidenceUnresolved`.", "why": "A-read", "file": "spec/21-memory.md", - "line": 140 + "line": 139 }, { "id": "read.recall.evidence-references", @@ -6421,7 +6421,7 @@ "statement": "A claim naming more than 256 entries of evidence is suppressed unresolved, raising `MemoryEvidenceOverflow`.", "why": "A-read", "file": "spec/21-memory.md", - "line": 142 + "line": 141 }, { "id": "read.recall.evidence-key", @@ -6432,7 +6432,7 @@ "statement": "The write landing a claim stamps each citation into a keyed table with a pepper-keyed digest of the cited row's key; recall resolves a citation whose row no longer reads through the digest's live version.", "why": "A-read", "file": "spec/21-memory.md", - "line": 144 + "line": 143 }, { "id": "read.recall.evidence-key-masked", @@ -6443,7 +6443,7 @@ "statement": "A citation whose key column reads masked or nulled by zone in the writer's session carries no digest; a masked or nulled column outside the key leaves the digest stamped.", "why": "because a masked key cell digests a value no other session reads, while a cell outside the key plays no part in matching it", "file": "spec/21-memory.md", - "line": 146 + "line": 145 }, { "id": "read.recall.evidence-no-value", @@ -6454,7 +6454,7 @@ "statement": "A citation stored in a memory row carries the cited table, run, sequence and key digest, and no cell value of the cited row.", "why": "A-read", "file": "spec/21-memory.md", - "line": 148 + "line": 147 }, { "id": "read.recall.evidence-stale", @@ -6465,7 +6465,7 @@ "statement": "A claim served through its key digest while its cited run and sequence no longer read counts under `stale` in the `contextful.recall` block, which names no claim.", "why": "A-read", "file": "spec/21-memory.md", - "line": 150 + "line": 149 }, { "id": "read.recall.ranked-arm", @@ -6476,7 +6476,7 @@ "statement": "A `corpus.retrieve` arm over a `memory_facts` table serves only live claims — no `superseded_by`, and a `valid_to` null or past the read's anchor — whose evidence passes the gate.", "why": "", "file": "spec/21-memory.md", - "line": 152 + "line": 151 }, { "id": "read.recall.suppression-count", @@ -6487,7 +6487,7 @@ "statement": "A suppressed claim is absent from the rows; the `contextful.recall` block counts suppressions per error identifier and names no claim.", "why": "because a count discloses that a conclusion was withheld, never what it concluded", "file": "spec/21-memory.md", - "line": 153 + "line": 152 }, { "id": "read.recall.keyed", @@ -6498,7 +6498,7 @@ "statement": "`memory.recall` takes `table`, `subject`, `observed_at`, `as_of_ingest` and `limit`, and returns the claims whose `subject` equals `subject` exactly and whose `valid_from` and `valid_to` cover `observed_at` as {{store.bound-time.valid-as-of}} does.", "why": "", "file": "spec/21-memory.md", - "line": 155 + "line": 154 }, { "id": "read.recall.cli-verb", @@ -6509,7 +6509,7 @@ "statement": "The `contextful memory recall` verb passes its arguments through {{read.recall.keyed}} and prints the same JSON response as the `memory.recall` tool.", "why": "", "file": "spec/21-memory.md", - "line": 156 + "line": 155 }, { "id": "read.recall.keyed-clocks", @@ -6520,7 +6520,7 @@ "statement": "`as_of_ingest` bounds the read as {{store.bound-time.as-of}} does; absent, the read takes the latest committed state, and an absent `observed_at` the call's instant. `contextful.bounds` echoes each supplied bound under its argument name, with a per-name `inclusive` map.", "why": "", "file": "spec/21-memory.md", - "line": 157 + "line": 156 }, { "id": "read.recall.keyed-history", @@ -6531,7 +6531,7 @@ "statement": "A claim a successor retired still answers an `observed_at` inside its validity; the keyed read filters on validity alone, never on `superseded_by`.", "why": "because the question is what held at that instant, and the retired claim is the answer its interval records", "file": "spec/21-memory.md", - "line": 158 + "line": 157 }, { "id": "read.recall.keyed-gate", @@ -6542,7 +6542,7 @@ "statement": "Every keyed claim passes {{read.recall.evidence-unresolved}} and {{read.recall.evidence-references}}, and the response carries {{read.recall.suppression-count}}.", "why": "", "file": "spec/21-memory.md", - "line": 160 + "line": 159 }, { "id": "read.recall.keyed-window", @@ -6553,7 +6553,7 @@ "statement": "The keyed read gates claims in order and stops once it keeps one claim past the row ceiling, so the suppression count covers the claims gated before that stop.", "why": "because a one-row recall over a subject with thousands of claims otherwise resolves every claim's evidence", "file": "spec/21-memory.md", - "line": 161 + "line": 160 }, { "id": "read.recall.keyed-order", @@ -6564,7 +6564,7 @@ "statement": "Keyed claims order by tier, `curated` first, then `valid_from`, newest first, then `claim_id`; `limit` and the table's ceilings bound them under {{read.respond.row-ceiling}}.", "why": "", "file": "spec/21-memory.md", - "line": 163 + "line": 162 }, { "id": "read.recall.keyed-not-claims", @@ -6575,7 +6575,7 @@ "statement": "A granted `table` declaring a shape other than `memory_facts`, or no memory shape, raises `MemoryRecallNotClaims`.", "why": "because a keyed read answers with a claim's subject, validity and evidence, which no other table carries", "file": "spec/21-memory.md", - "line": 164 + "line": 163 }, { "id": "read.recall.stale-revision", @@ -6586,7 +6586,7 @@ "statement": "A stale citation remains subject to {{read.recall.keyed-gate}}; only a newly committed source row advancing {{read.synthesize.pass-cursor}} starts synthesis against its live key version.", "why": "A-read", "file": "spec/21-memory.md", - "line": 166 + "line": 165 }, { "id": "read.recall.usage-ledger", @@ -6597,7 +6597,7 @@ "statement": "Each `memory.recall` and ranked memory arm records the ids of claims actually returned in the request ledger, under the caller's admitted authority and the read frontier.", "why": "A-read", "file": "spec/21-memory.md", - "line": 168 + "line": 167 }, { "id": "read.resolve-entity.ambiguous-mention", @@ -6608,7 +6608,7 @@ "statement": "A mention matching two canonical identities with no deterministic key separating them is recorded as an ambiguous skip, raises `MemoryEntityAmbiguous`, and dead-letters its candidate claim.", "why": "A-read", "file": "spec/21-memory.md", - "line": 186 + "line": 185 }, { "id": "read.resolve-entity.edge-endpoint", @@ -6619,7 +6619,7 @@ "statement": "A candidate edge whose source or target resolves to no identity is dead-lettered, raising `MemoryEdgeEndpointUnresolved`.", "why": "A-read", "file": "spec/21-memory.md", - "line": 188 + "line": 187 }, { "id": "read.resolve-entity.graph-index", @@ -6630,7 +6630,7 @@ "statement": "An external graph engine is a derived index of admitted memory edges; an entity or ownership answer resolves through the store's enforced rows at the read frontier.", "why": "A-read", "file": "spec/21-memory.md", - "line": 190 + "line": 189 }, { "id": "read.resolve-entity.ownership-answer", @@ -6641,7 +6641,7 @@ "statement": "An artifact's ownership answer returns every attached principal visible to the caller, newest attachment first, with principal id breaking equal-instant ties.", "why": "A-read", "file": "spec/21-memory.md", - "line": 192 + "line": 191 }, { "id": "read.settle.registration", @@ -6652,7 +6652,7 @@ "statement": "A registration names exactly one form (relative horizon, absolute deadline, open watch) and one source (`metric`, `adjudicator`, `manual`), with a comparator exactly when the source is `metric`; otherwise it raises `OutcomeRegistrationInvalid`.", "why": "A-read", "file": "spec/21-memory.md", - "line": 200 + "line": 199 }, { "id": "read.settle.source-mismatch", @@ -6663,7 +6663,7 @@ "statement": "An observation carrying a verdict whose resolution source is absent or differs from the registration's raises `OutcomeSourceMismatch`.", "why": "A-read", "file": "spec/21-memory.md", - "line": 202 + "line": 201 }, { "id": "read.settle.settling-citation", @@ -6674,7 +6674,7 @@ "statement": "An `adjudicator` or `manual` verdict without an `http` or `https` settling citation raises `OutcomeCitationMissing`; the citation rides the label view.", "why": "A-read", "file": "spec/21-memory.md", - "line": 204 + "line": 203 }, { "id": "read.settle.grace-window", @@ -6685,7 +6685,7 @@ "statement": "The label join keeps an observation from the prediction instant through the deadline plus an inclusive grace of 86400 s.", "why": "", "file": "spec/21-memory.md", - "line": 206 + "line": 205 }, { "id": "read.settle.calibration-report", @@ -6696,7 +6696,7 @@ "statement": "Calibration reports group settled predictions by shape and predicate, and name sample count, Brier score, expected calibration error and the held-out score used by {{read.synthesize.confidence-calibration}}.", "why": "A-read", "file": "spec/21-memory.md", - "line": 207 + "line": 206 }, { "id": "run.journal.step-output", From 410c444e928ca53436d2a334795156d86bc64648 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Fri, 9 Oct 2026 01:41:49 +0800 Subject: [PATCH 6/8] test(memory): refuse a served claim session holding the scope separator --- crates/contextful-cli/tests/integration/serve.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/contextful-cli/tests/integration/serve.rs b/crates/contextful-cli/tests/integration/serve.rs index c1239b947..f1ba95fbe 100644 --- a/crates/contextful-cli/tests/integration/serve.rs +++ b/crates/contextful-cli/tests/integration/serve.rs @@ -261,6 +261,12 @@ fn served_claim_write_is_durable_actor_bound_and_outside_read_mcp() { borrowed["session"] = json!("session-2"); assert_eq!(post_claim(&addr, &alice, &borrowed, false).0, 403, "a writer cannot pick another session"); assert_eq!(post_claim(&addr, &bob, &claim, false).0, 403, "the second operator cannot author Alice's claim"); + let colon = mint("user://alice", "session:1", &["read", "write"]); + let mut colon_claim = claim.clone(); + colon_claim["session"] = json!("session:1"); + let (status, refused) = post_claim(&addr, &colon, &colon_claim, false); + assert_eq!((status, refused["error"]["identifier"].clone()), (403, json!("MemoryClaimScopeRefused")), + "a session holding the scope separator cannot alias another actor's scope"); let write_tool = json!({ "jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": { "name": "memory.write", "arguments": claim } }); let (_, mcp) = post(&addr, &write_tool, &alice, None); From d45b56347a82943d756bdaa4b9c6f711640ca910 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Fri, 9 Oct 2026 01:42:34 +0800 Subject: [PATCH 7/8] fix(memory): refuse a served claim session holding the scope separator --- crates/contextful-cli/src/serve.rs | 2 +- spec/21-memory.md | 2 +- spec/spec.lock.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/contextful-cli/src/serve.rs b/crates/contextful-cli/src/serve.rs index afe90841e..dfe689f1e 100644 --- a/crates/contextful-cli/src/serve.rs +++ b/crates/contextful-cli/src/serve.rs @@ -177,7 +177,7 @@ pub fn run(args: ServeArgs, tasks: &Tasks) -> Result<()> { "error": { "http": 400, "identifier": "MemoryClaimMalformed" } })), }; - if body.actor.is_empty() || body.session.is_empty() || + if body.actor.is_empty() || body.session.is_empty() || body.session.contains(':') || authority.subject().on_behalf_of() != Some(body.actor.as_str()) || authority.subject().task() != Some(body.session.as_str()) { return contextful_agent::http::HttpResponse::json(403, &serde_json::json!({ diff --git a/spec/21-memory.md b/spec/21-memory.md index a787d5902..f8e284466 100644 --- a/spec/21-memory.md +++ b/spec/21-memory.md @@ -121,7 +121,7 @@ Supersession within one validity line, confidence decay, the direct write and it *A-read* - `served-write` — A writable served face answers `POST /memory/claims` under a per-request network credential, lands a declared claim through the direct write, and keeps the read MCP tool set closed. *A-read* -- `served-scope` — A claim write whose `actor` or `session` differs from the admitted credential's `on_behalf_of` or `task` raises `MemoryClaimScopeRefused`; accepted claims take scope from those members, never from the payload. +- `served-scope` — A claim write whose `actor` or `session` differs from the admitted credential's `on_behalf_of` or `task`, or whose `session` holds a colon, raises `MemoryClaimScopeRefused`; accepted claims take scope from those members, never from the payload. *A-read* - `browser-request` — An `Origin` header on the served claim-write route raises `MemoryClaimBrowserRefused` before credential admission and commits nothing. *A-read* diff --git a/spec/spec.lock.json b/spec/spec.lock.json index e9f9fe319..a0e6bb3a2 100644 --- a/spec/spec.lock.json +++ b/spec/spec.lock.json @@ -6352,7 +6352,7 @@ "operation": "revise", "subject": "served-scope", "kind": "refusal", - "statement": "A claim write whose `actor` or `session` differs from the admitted credential's `on_behalf_of` or `task` raises `MemoryClaimScopeRefused`; accepted claims take scope from those members, never from the payload.", + "statement": "A claim write whose `actor` or `session` differs from the admitted credential's `on_behalf_of` or `task`, or whose `session` holds a colon, raises `MemoryClaimScopeRefused`; accepted claims take scope from those members, never from the payload.", "why": "A-read", "file": "spec/21-memory.md", "line": 124 From ef2734e74b2eea096d9a6de5d0f7abb8a4ab0b4e Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Fri, 9 Oct 2026 01:55:31 +0800 Subject: [PATCH 8/8] Regenerate spec status --- spec/status.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/spec/status.md b/spec/status.md index fd7786100..9339fdf13 100644 --- a/spec/status.md +++ b/spec/status.md @@ -10,12 +10,12 @@ An unpinned clause is `committed`; a pinned one is `performed` when its Rust, Le | `connector` | 2 | 14 | 349 | 89 | 21 | 15 | 229 | 0 | 229 | | `corpus` | 1 | 10 | 79 | 37 | 7 | 0 | 43 | 0 | 43 | | `disclosure` | 3 | 16 | 134 | 66 | 13 | 3 | 63 | 0 | 63 | -| `read` | 2 | 15 | 200 | 31 | 22 | 9 | 178 | 0 | 178 | +| `read` | 2 | 15 | 206 | 35 | 22 | 9 | 183 | 0 | 183 | | `run` | 3 | 26 | 406 | 102 | 39 | 28 | 306 | 0 | 306 | | `store` | 1 | 17 | 252 | 59 | 13 | 19 | 216 | 0 | 216 | | `surface` | 2 | 20 | 118 | 53 | 22 | 0 | 72 | 0 | 72 | | `topology` | 1 | 6 | 70 | 24 | 5 | 5 | 38 | 0 | 38 | -| **total** | 19 | 155 | 2124 | 597 | 191 | 103 | 1497 | 0 | | +| **total** | 19 | 155 | 2130 | 601 | 191 | 103 | 1502 | 0 | | Decision records: 18. @@ -32,7 +32,7 @@ A milestone reads `closed` when its acceptance test computes `passing` and every | 4 — Ingest | 25 | 517 | 344 | passing | open | | 5 — The read face under enforcement | 17 | 262 | 204 | passing | open | | 6 — Sync and replicas | 8 | 78 | 73 | passing | closed | -| 7 — Memory | 6 | 48 | 40 | passing | closed | +| 7 — Memory | 6 | 54 | 45 | passing | closed | | 8 — Accountability | 5 | 60 | 47 | passing | open | | 9 — Visibility | 6 | 41 | 3 | open | open | | 10 — Cadence and the operator plane | 11 | 76 | 64 | passing | open | @@ -893,11 +893,16 @@ Unscheduled operations: 10. | `read.retrieve.token-cap` | `crates/contextful-core/tests/integration/read/retrieve.rs::the_token_set_is_capped_and_an_empty_set_omits_the_floor` | performed | | `read.retrieve.token-length-floor` | `crates/contextful-core/tests/integration/read/retrieve.rs::an_ascii_run_below_the_length_floor_leaves` | performed | | `read.retrieve.unsatisfiable-arm-drops` | `crates/contextful-context/tests/integration/read/retrieve.rs::a_filter_binds_named_columns_and_a_table_lacking_one_drops_its_arm` | performed | +| `read.revise.browser-request` | `spec/pins.toml` | performed | | `read.revise.citation-live` | `crates/contextful-memory/tests/integration/evidence.rs::a_write_citing_a_superseded_version_refuses` | performed | | `read.revise.dedup-key` | `crates/contextful-memory/tests/integration/write.rs::a_dedup_key_seeds_the_claim_id_and_a_retry_lands_nothing` | performed | | `read.revise.direct-write` | `crates/contextful-core/tests/integration/memory/revise.rs::the_direct_write_accepts_claims_alone` | performed | +| `read.revise.malformed-request` | `spec/pins.toml` | performed | | `read.revise.observed-at` | `crates/contextful-memory/tests/integration/write.rs::an_observed_write_lands_valid_from_its_observed_instant` | performed | | `read.revise.observed-order` | `crates/contextful-memory/tests/integration/write.rs::an_observation_before_a_live_contradicting_prior_refuses` | performed | +| `read.revise.served-dedup` | `spec/pins.toml` | performed | +| `read.revise.served-scope` | `spec/pins.toml` | performed | +| `read.revise.served-write` | `spec/pins.toml` | performed | | `read.revise.supersede` | `crates/contextful-core/tests/integration/memory/revise.rs::an_equal_or_higher_claim_retires_its_prior_on_one_line` | performed | | `read.revise.tier` | `crates/contextful-core/tests/integration/memory/revise.rs::tier_follows_the_write_path_and_the_lowest_grounding` | performed | | `read.revise.unscoped-collision` | `crates/contextful-memory/tests/integration/write.rs::unscoped_writers_at_one_instant_record_a_conflict_without_retirement` | performed |