diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ffa493..aec6b0b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,13 @@ flag, or page that no existing user can encounter without opting into it. - Quoting a filepath while controlling an agent through remote `spt rc` can now offer that agent a fetch command for the live file on your machine. The reference lasts up to 24 hours and is addressed only to that agent. Repeated reports do not - extend it or repeat the notice. Missing files stay silent; unavailable provenance - and other refusals are named. No harness-adapter changes are required. + extend it or repeat the notice. No quoted paths means no broker IPC or line; + missing files and absent hosted sessions or local/viewer-only/no-controller seats + stay silent. CLI broker-connect and unanswered-receipt diagnostics require + `SPT_PUMP_TRACE`; failures with a remote controller, including the broker's + 10-second owner-reply timeout, remain named. Core-written peer deliveries are + excluded by exact or ASCII-edge-trimmed byte comparison, never message-shape + parsing or interior normalization. No harness-adapter changes are required. ## [0.69.0] - 2026-09-11 diff --git a/crates/spt-daemon/src/broker.rs b/crates/spt-daemon/src/broker.rs index 94d580c3..bafb8eff 100644 --- a/crates/spt-daemon/src/broker.rs +++ b/crates/spt-daemon/src/broker.rs @@ -8975,43 +8975,48 @@ impl Broker { fn dispatch_user_input_report(&self, env: Envelope) -> Result<(), String> { let req: crate::msg::UserInputReport = serde_json::from_value(env.payload) .map_err(|e| format!("bad USER_INPUT report: {e}"))?; + let (log, input, reports) = { + let sessions = recover(&self.sessions); + let Some(h) = sessions.values().find(|h| h.endpoint == req.endpoint) else { + return Ok(()); + }; + (Arc::clone(&h.log), Arc::clone(&h.input), Arc::clone(&h.input_reports)) + }; + // Capture the seat before any auth-store or helper work. Ordinary local, + // absent, and viewer-only sessions owe neither serving nor a diagnostic. + let (controller_conn, controller_tx, by) = { + let mut log = recover_log(&log); + log.reap_dead_controller(); + let Some(controller) = log.controller.as_ref() else { return Ok(()); }; + if controller.by.is_none() { return Ok(()); } + (controller.send.id(), controller.tx.clone(), controller.by.clone()) + }; + let local_node = crate::access::local_node_hex(); + if crate::inputreceipt::remote_origin(Some(by.as_deref()), local_node.as_deref()) + .map_err(str::to_owned)?.is_none() + { + return Ok(()); + } let perch = resolve_perch_path(&req.endpoint, ParentHint::Infer); let rec = spt_store::info::read_info(&perch) .ok_or("report endpoint has no authenticated session")?; if req.session.is_empty() || req.session != rec.session_id { return Err("report session authentication failed".into()); } - let (log, input, reports) = { - let sessions = recover(&self.sessions); - let h = sessions.values().find(|h| h.endpoint == req.endpoint) - .ok_or("no broker-held controller seat")?; - (Arc::clone(&h.log), Arc::clone(&h.input), Arc::clone(&h.input_reports)) - }; - // The source classifier is consulted before attributing any payload. + if req.expires_at_ms <= crate::brain::now_ms() { + return Err("input report expired before broker receipt".into()); + } + // Status first: an in-progress physical write has not published its + // digest yet. A remote report must not guess while evidence is uncertain. if let Some(reason) = input.delivery_unavailable_reason() { return Err(reason.into()); } if input.delivery_matches(req.payload.as_bytes()) { return Err("payload matches core-written PTY delivery bytes".into()); } - let local_node = crate::access::local_node_hex(); - let (request, controller_tx) = { - let mut log = recover_log(&log); - if req.expires_at_ms <= crate::brain::now_ms() { - return Err("input report expired before broker receipt".into()); - } - log.reap_dead_controller(); - crate::inputreceipt::remote_origin( - log.controller.as_ref().map(|c| c.by.as_deref()), - !log.viewers.is_empty(), - local_node.as_deref(), - ).map_err(str::to_owned)?; - let controller = log.controller.as_ref().expect("remote origin requires a controller"); - let request = recover(&reports).begin( - &req.session, &req.payload, controller.send.id(), crate::brain::now_ms(), - ).map_err(str::to_owned)?; - (request, controller.tx.clone()) - }; + let request = recover(&reports).begin( + &req.session, &req.payload, controller_conn, crate::brain::now_ms(), + ).map_err(str::to_owned)?; let Some(request) = request else { return Ok(()); }; let frame = Envelope::new(crate::msg::KIND_USER_INPUT_PATHS, serde_json::to_value(&request).expect("InputPathRequest serializes")); @@ -10416,7 +10421,7 @@ mod tests { // [unit->REQ-INPUT-PROVENANCE-ACCEPTANCE-REPORT] #[test] - fn input_receipt_keeps_its_controller_across_take_and_publishes_once() { + fn input_receipt_local_is_silent_then_remote_stays_bound_across_take() { use spt_net::net::attach::{InputPathOutcome, InputPathReply, InputPathRequest, InputPathResult}; crate::test_home::with_home(|_| { struct Child(Arc); @@ -10451,10 +10456,16 @@ mod tests { "endpoint": endpoint, "session": session, "payload": "read /input", "expires_at_ms": crate::brain::now_ms() + crate::msg::USER_INPUT_REPORT_BOUND.as_millis() as u64, })); - assert!(broker.dispatch_user_input_report(report("wrong-session")).is_err()); - assert!(broker.dispatch_user_input_report(report("session")) - .unwrap_err().contains("local controller")); + // A local seat is an ordinary silent no-op, even without session + // proof. No helper observation is created. + broker.dispatch_user_input_report(report("wrong-session")).unwrap(); + broker.dispatch_user_input_report(report("session")).unwrap(); + let mut unhosted = report("session"); + unhosted.payload["endpoint"] = serde_json::json!("not-hosted"); + broker.dispatch_user_input_report(unhosted).unwrap(); + assert!(spt_store::helperline::read_at(&perch).is_empty()); recover_log(&log).become_controller(Arc::clone(&original), Some("11".repeat(32)), 0, 1); + assert!(broker.dispatch_user_input_report(report("wrong-session")).is_err()); let mut expired = report("session"); expired.payload["expires_at_ms"] = serde_json::json!(0); assert!(broker.dispatch_user_input_report(expired).unwrap_err().contains("expired")); diff --git a/crates/spt-daemon/src/deliverybytes.rs b/crates/spt-daemon/src/deliverybytes.rs index 8ee0f959..2e399c21 100644 --- a/crates/spt-daemon/src/deliverybytes.rs +++ b/crates/spt-daemon/src/deliverybytes.rs @@ -17,19 +17,24 @@ pub(crate) struct DeliveryBytes { #[derive(Default)] struct Completed { digests: HashSet<[u8; 32]>, + trimmed_digests: HashSet<[u8; 32]>, exhausted: bool, writing: bool, unproven: bool, } impl DeliveryBytes { + // [impl->REQ-INPUT-PROVENANCE-ACCEPTANCE-REPORT] pub(crate) fn matches(&self, payload: &[u8]) -> bool { let digest: [u8; 32] = Sha256::digest(payload).into(); - self.completed - .lock() - .unwrap_or_else(|p| p.into_inner()) - .digests - .contains(&digest) + let trimmed = payload.trim_ascii(); + let trimmed_digest = if trimmed.len() == payload.len() { + digest + } else { + Sha256::digest(trimmed).into() + }; + let completed = self.completed.lock().unwrap_or_else(|p| p.into_inner()); + completed.digests.contains(&digest) || completed.trimmed_digests.contains(&trimmed_digest) } pub(crate) fn unavailable_reason(&self) -> Option<&'static str> { @@ -58,11 +63,11 @@ impl DeliveryBytes { completed.writing = false; } - fn remember(&self, digest: [u8; 32]) { - self.remember_with_limit(digest, MAX_DELIVERY_DIGESTS); + fn remember(&self, digest: [u8; 32], trimmed_digest: [u8; 32]) { + self.remember_with_limit(digest, trimmed_digest, MAX_DELIVERY_DIGESTS); } - fn remember_with_limit(&self, digest: [u8; 32], limit: usize) { + fn remember_with_limit(&self, digest: [u8; 32], trimmed_digest: [u8; 32], limit: usize) { let mut completed = self.completed.lock().unwrap_or_else(|p| p.into_inner()); if completed.digests.contains(&digest) { return; @@ -71,6 +76,8 @@ impl DeliveryBytes { completed.exhausted = true; } else { completed.digests.insert(digest); + // Both comparisons share one candidate quota and publication lock. + completed.trimmed_digests.insert(trimmed_digest); } } } @@ -85,10 +92,33 @@ pub(crate) struct DeliveryAttempt { #[derive(Default)] struct AttemptText { hash: Sha256, + trimmed_hash: Sha256, + trimmed_end: Option, has_text: bool, failed: bool, } +impl AttemptText { + // [impl->REQ-INPUT-PROVENANCE-ACCEPTANCE-REPORT] + fn update(&mut self, bytes: &[u8]) { + self.hash.update(bytes); + self.has_text |= !bytes.is_empty(); + let bytes = if self.trimmed_end.is_none() { + bytes.trim_ascii_start() + } else { + bytes + }; + let content_len = bytes.trim_ascii_end().len(); + if content_len != 0 { + self.trimmed_hash.update(&bytes[..content_len]); + // Snapshot the last non-whitespace byte; later chunks may turn the + // pending trailing whitespace into exact interior content. + self.trimmed_end = Some(self.trimmed_hash.clone()); + } + self.trimmed_hash.update(&bytes[content_len..]); + } +} + #[derive(Clone, Copy)] pub(crate) enum DeliveryPart { Text { end: bool }, @@ -106,13 +136,19 @@ impl DeliveryAttempt { return; } if let DeliveryPart::Text { end } = part { - text.hash.update(bytes); - text.has_text |= !bytes.is_empty(); + text.update(bytes); if end && text.has_text { // Publish each complete Text command's cumulative text, including - // exact embedded newlines. Keys are source-tagged controls, never - // guessed framing stripped from the reported payload. - ledger.remember(text.hash.clone().finalize().into()); + // exact embedded newlines, plus its ASCII-edge-trimmed digest. + // Keys remain source-tagged controls, not guessed framing. + ledger.remember( + text.hash.clone().finalize().into(), + text.trimmed_end + .clone() + .unwrap_or_default() + .finalize() + .into(), + ); } } } @@ -214,7 +250,7 @@ mod tests { // [unit->REQ-INPUT-PROVENANCE-ACCEPTANCE-REPORT] #[test] - fn split_multiline_delivery_matches_before_commit_without_normalizing() { + fn split_multiline_delivery_matches_before_commit_with_ascii_edge_trimming() { let ledger = DeliveryBytes::default(); let attempt = Arc::new(DeliveryAttempt::default()); let first = InputRecord::delivery( @@ -236,11 +272,81 @@ mod tests { InputRecord::delivery(b"\r".to_vec(), &attempt, DeliveryPart::Key).write_to(&Sink, &ledger); assert!(ledger.matches(payload)); assert!(!ledger.matches(b"\n C:/b\n ")); - assert!(!ledger.matches(b"\n C:/a\n")); + assert!(ledger.matches(b"\n C:/a\n")); assert!(!ledger.matches(b"\r\n C:/a\r\n ")); + assert!(!ledger.matches(b"\nC:/a\n")); assert!(!ledger.matches(b"\r")); } + // [unit->REQ-INPUT-PROVENANCE-ACCEPTANCE-REPORT] + #[test] + fn trimmed_harness_resubmission_matches_core_written_path_bearing_peer_message() { + let ledger = DeliveryBytes::default(); + let attempt = Arc::new(DeliveryAttempt::default()); + let delivered = + b" \t\r\n\nInspect C:/private/report.txt\n\r\n\t "; + let reported = b"\nInspect C:/private/report.txt\n"; + assert!(!ledger.matches(reported)); + InputRecord::delivery( + delivered.to_vec(), + &attempt, + DeliveryPart::Text { end: true }, + ) + .write_to(&Sink, &ledger); + assert!(ledger.matches(delivered)); + assert!(ledger.matches(reported)); + assert!( + ledger.matches(b"\x0c\nInspect C:/private/report.txt\n\t") + ); + assert!(!ledger.matches(b"\nInspect C:/private/other.txt\n")); + } + + // [unit->REQ-INPUT-PROVENANCE-ACCEPTANCE-REPORT] + #[test] + fn ascii_edge_trimming_spans_chunks_without_changing_interior_whitespace() { + let ledger = DeliveryBytes::default(); + let attempt = Arc::new(DeliveryAttempt::default()); + let chunks: &[&[u8]] = &[ + b" \t", b"\r\n", b"", b"peer ", b"\t", b"\r\n", b"C:/", b"private", b"\r", b"\n ", + b"\t", + ]; + let reported = b"peer \t\r\nC:/private"; + for (index, bytes) in chunks.iter().enumerate() { + assert!(!ledger.matches(reported)); + InputRecord::delivery( + bytes.to_vec(), + &attempt, + DeliveryPart::Text { + end: index == chunks.len() - 1, + }, + ) + .write_to(&Sink, &ledger); + } + assert!(ledger.matches(b" \t\r\npeer \t\r\nC:/private\r\n \t")); + assert!(ledger.matches(reported)); + assert!(ledger.matches(b"\npeer \t\r\nC:/private ")); + assert!(!ledger.matches(b"peer C:/private")); + assert!(!ledger.matches(b"peer \t\nC:/private")); + assert!(!ledger.matches(b"peer \r\t\nC:/private")); + } + + // [unit->REQ-INPUT-PROVENANCE-ACCEPTANCE-REPORT] + #[test] + fn non_ascii_edge_whitespace_remains_delivery_content() { + let ledger = DeliveryBytes::default(); + let attempt = Arc::new(DeliveryAttempt::default()); + InputRecord::delivery( + " \u{a0}peer C:/private\u{3000}\n".as_bytes().to_vec(), + &attempt, + DeliveryPart::Text { end: true }, + ) + .write_to(&Sink, &ledger); + assert!(ledger.matches("\u{a0}peer C:/private\u{3000}".as_bytes())); + assert!(!ledger.matches("peer C:/private\u{3000}".as_bytes())); + assert!(!ledger.matches("\u{a0}peer C:/private".as_bytes())); + assert!(!ledger.matches(b"peer C:/private")); + } + // [unit->REQ-INPUT-PROVENANCE-ACCEPTANCE-REPORT] #[test] fn controller_and_probe_bytes_never_become_delivery_evidence() { @@ -355,6 +461,8 @@ mod tests { InputRecord::delivery(b"two".to_vec(), &attempt, DeliveryPart::Text { end: true }) .write_to(&Sink, &ledger); assert!(ledger.matches(b"one\ntwo")); + assert!(ledger.matches(b"\tone\ntwo\r\n")); + assert!(!ledger.matches(b"onetwo")); assert!(!ledger.matches(b"one\n\x1b[Atwo")); let next_attempt = Arc::new(DeliveryAttempt::default()); InputRecord::delivery( @@ -371,12 +479,21 @@ mod tests { #[test] fn exhausted_evidence_keeps_old_matches_and_declines_future_origin_grants() { let ledger = DeliveryBytes::default(); - ledger.remember_with_limit(Sha256::digest(b"first").into(), 1); - ledger.remember_with_limit(Sha256::digest(b"first").into(), 1); + let first = Sha256::digest(b" first ").into(); + let trimmed_first = Sha256::digest(b"first").into(); + ledger.remember_with_limit(first, trimmed_first, 1); + ledger.remember_with_limit(first, trimmed_first, 1); assert_eq!(ledger.unavailable_reason(), None); - ledger.remember_with_limit(Sha256::digest(b"second").into(), 1); + ledger.remember_with_limit( + Sha256::digest(b" second ").into(), + Sha256::digest(b"second").into(), + 1, + ); assert!(ledger.matches(b"first")); + assert!(ledger.matches(b" first ")); + assert!(ledger.matches(b"\tfirst\r\n")); assert!(!ledger.matches(b"second")); + assert!(!ledger.matches(b" second ")); assert_eq!( ledger.unavailable_reason(), Some("delivery-evidence-capacity") diff --git a/crates/spt-daemon/src/inputreceipt.rs b/crates/spt-daemon/src/inputreceipt.rs index 926292c7..d1aead1d 100644 --- a/crates/spt-daemon/src/inputreceipt.rs +++ b/crates/spt-daemon/src/inputreceipt.rs @@ -15,16 +15,13 @@ const PENDING_RECEIPTS: usize = 64; // [impl->REQ-INPUT-PROVENANCE-ACCEPTANCE-REPORT] pub(crate) fn remote_origin<'a>( controller: Option>, - viewers: bool, local_node: Option<&str>, -) -> Result<&'a str, &'static str> { +) -> Result, &'static str> { match controller { - None if viewers => Err("viewer-only: no controller seat"), - None => Err("no controller seat"), - Some(None) => Err("local controller seat"), - Some(Some(by)) if Some(by) == local_node => Err("local controller seat"), + None | Some(None) => Ok(None), + Some(Some(by)) if Some(by) == local_node => Ok(None), Some(Some(_)) if local_node.is_none() => Err("local node identity unavailable"), - Some(Some(by)) => Ok(by), + Some(Some(by)) => Ok(Some(by)), } } @@ -206,24 +203,15 @@ mod tests { #[test] fn receipt_origin_requires_a_remote_controller_not_a_viewer_or_local_seat() { assert_eq!( - remote_origin(Some(Some("remote")), true, Some("local")), - Ok("remote") + remote_origin(Some(Some("remote")), Some("local")), + Ok(Some("remote")) ); + assert_eq!(remote_origin(Some(None), Some("local")), Ok(None)); + assert_eq!(remote_origin(Some(Some("local")), Some("local")), Ok(None)); + assert_eq!(remote_origin(None, Some("local")), Ok(None)); assert_eq!( - remote_origin(Some(None), true, Some("local")), - Err("local controller seat") - ); - assert_eq!( - remote_origin(Some(Some("local")), false, Some("local")), - Err("local controller seat") - ); - assert_eq!( - remote_origin(None, true, Some("local")), - Err("viewer-only: no controller seat") - ); - assert_eq!( - remote_origin(None, false, Some("local")), - Err("no controller seat") + remote_origin(Some(Some("remote")), None), + Err("local node identity unavailable"), ); } diff --git a/crates/spt/src/api/nowsignal.rs b/crates/spt/src/api/nowsignal.rs index f3bb2b4a..74f24784 100644 --- a/crates/spt/src/api/nowsignal.rs +++ b/crates/spt/src/api/nowsignal.rs @@ -1084,6 +1084,28 @@ pub fn resolve_spec(ctx: &Ctx, spec_manifest: bool, spec_file: Option<&Path>) -> // [impl->REQ-INPUT-PROVENANCE-ACCEPTANCE-REPORT] pub(super) fn report_user_input(id: &str, session: &str, payload: &str) { use spt_daemon::brain::{Brain, PumpTrace}; + report_user_input_with(id, session, payload, |report| { + Brain::cold_start_pump( + &spt_daemon::endpoint::broker_socket_name(), + now_ms(), + spt_daemon::msg::USER_INPUT_REPORT_BOUND, + PumpTrace::Silent, + ).and_then(|mut brain| brain.report_user_input(report)) + }); +} + +// The effect boundary keeps the common path entirely outside IPC and threads. +// [impl->REQ-INPUT-PROVENANCE-ACCEPTANCE-REPORT] +fn report_user_input_with( + id: &str, + session: &str, + payload: &str, + send_report: impl FnOnce(spt_daemon::msg::UserInputReport) + -> std::io::Result + Send + 'static, +) { + if spt_store::helperline::quoted_path_candidates(payload, 1).is_empty() { + return; + } use spt_daemon::msg::{UserInputReport, USER_INPUT_REPORT_BOUND}; let report = UserInputReport { endpoint: id.to_owned(), @@ -1095,12 +1117,7 @@ pub(super) fn report_user_input(id: &str, session: &str, payload: &str) { // This CLI's one receipt worker also bounds connect, hello, and writes: // pump read deadlines alone do not bound a stalled initial handshake. let worker = std::thread::Builder::new().name("input-receipt".into()).spawn(move || { - let result = Brain::cold_start_pump( - &spt_daemon::endpoint::broker_socket_name(), - now_ms(), - USER_INPUT_REPORT_BOUND, - PumpTrace::Silent, - ).and_then(|mut brain| brain.report_user_input(report)); + let result = send_report(report); let _ = tx.send(result); }); let result = worker.and_then(|_| { @@ -1109,7 +1126,10 @@ pub(super) fn report_user_input(id: &str, session: &str, payload: &str) { }); let declined = match result { Ok(reply) => reply.declined, - Err(error) => Some(format!("receipt broker unanswered: {error}")), + Err(error) if spt_daemon::brain::PumpTrace::from_env() == spt_daemon::brain::PumpTrace::Stderr => { + Some(format!("receipt broker unanswered: {error}")) + } + Err(_) => None, }; if let Some(reason) = declined { spt_proto::emit_line_err!( @@ -1196,6 +1216,30 @@ pub fn cmd_now_signal( mod tests { use super::*; + // [unit->REQ-INPUT-PROVENANCE-ACCEPTANCE-REPORT] + #[test] + fn no_path_payload_never_calls_the_receipt_transport() { + struct Probe { + result: std::sync::mpsc::Sender, + called: bool, + } + impl Probe { + fn called(&mut self) { self.called = true; } + } + impl Drop for Probe { + fn drop(&mut self) { let _ = self.result.send(self.called); } + } + let (tx, rx) = std::sync::mpsc::channel(); + let mut probe = Probe { result: tx, called: false }; + report_user_input_with("ordinary", "session", "hello, carry on", move |_| { + probe.called(); + Err(std::io::Error::other("unexpected broker call")) + }); + // The transport closure must be dropped WITHOUT invocation. Waiting for + // its drop also catches an erroneously spawned but delayed worker. + assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(2)), Ok(false)); + } + fn session_id(name: &str) -> String { format!("now-signal-test-{}-{}", std::process::id(), name) } diff --git a/docs-site/src/changelog.md b/docs-site/src/changelog.md index 2b8e2ecb..58cc8acb 100644 --- a/docs-site/src/changelog.md +++ b/docs-site/src/changelog.md @@ -19,8 +19,13 @@ flag, or page that no existing user can encounter without opting into it. - Quoting a filepath while controlling an agent through remote `spt rc` can now offer that agent a fetch command for the live file on your machine. The reference lasts up to 24 hours and is addressed only to that agent. Repeated reports do not - extend it or repeat the notice. Missing files stay silent; unavailable provenance - and other refusals are named. No harness-adapter changes are required. + extend it or repeat the notice. No quoted paths means no broker IPC or line; + missing files and absent hosted sessions or local/viewer-only/no-controller seats + stay silent. CLI broker-connect and unanswered-receipt diagnostics require + `SPT_PUMP_TRACE`; failures with a remote controller, including the broker's + 10-second owner-reply timeout, remain named. Core-written peer deliveries are + excluded by exact or ASCII-edge-trimmed byte comparison, never message-shape + parsing or interior normalization. No harness-adapter changes are required. ## [0.69.0] - 2026-09-11 diff --git a/docs-site/src/serving/attachments.md b/docs-site/src/serving/attachments.md index eb9063d4..e3dbf6ea 100644 --- a/docs-site/src/serving/attachments.md +++ b/docs-site/src/serving/attachments.md @@ -123,20 +123,30 @@ poll once the owner answers; input processing never waits for that answer. - **Absolute or `~`-rooted only.** The owner resolves `~` against its own home. A relative path has no anchor on another node. At most **five** paths per report; directories receive the same lifetime and audience. -- A **missing file is silent**. Other non-grants name their reason, including - local/viewer/no controller seat, an invalid session, uncertain delivery-byte - evidence, or an owner that did not answer. +- **No quoted paths means no broker IPC and no line.** An absent hosted session, + local or viewer-only seat, or no controller binds nothing silently. A missing + file on the bound owner's machine is also silent, with no fallback origin. +- CLI broker-connect and unanswered-receipt failures are silent unless + `SPT_PUMP_TRACE` enables diagnostics. Named declines apply only to failures with + a remote controller, such as an invalid session, uncertain delivery-byte evidence, + or owner refusal. The broker's **10-second owner-reply timeout** remains named. - A repeated payload in the same harness session does not register again, repeat the helper notice, or extend an existing reference's deadline. -- Core excludes bytes it knows it physically wrote for peer delivery, rather than - guessing from message syntax. This is **receipt-seat attribution**, not proof - of who typed every byte: external automation and native harness re-submission - cannot be distinguished beyond the current controller seat. No adapter token - or integration change is required. +- Core excludes bytes it knows it physically wrote for peer delivery: either an + exact match or a match after trimming ASCII whitespace from both byte sequences' + ends. It does not parse message shapes or normalize interior text. This is + **receipt-seat attribution**, not proof of who typed every byte: external + automation and native harness re-submission cannot be distinguished beyond the + current controller seat. No adapter token or integration change is required. Every entry the helper registers is enumerable in `spt serve list` with its origin, so an automatic exposure is exactly as visible as a deliberate one. +Two-node field acceptance remains pending, separate from doc/impl/unit evidence. +It must include a remote controller remaining seated while core physically delivers +a path-bearing peer message, whose input report must cause **no serve**, and measure +the added hook cost. Neither obligation is claimed as executed here. + ## Reading a message back diff --git a/docs/INPUT-PROVENANCE-CONTRACT.md b/docs/INPUT-PROVENANCE-CONTRACT.md index b7bde5c7..e7de8ceb 100644 --- a/docs/INPUT-PROVENANCE-CONTRACT.md +++ b/docs/INPUT-PROVENANCE-CONTRACT.md @@ -30,9 +30,9 @@ EXPERIENCE. Enqueue/dequeue ordering and human-versus-machine injection remain * resolve** — they are not reasons to reopen the settled experience, and an adapter answer that disputes the experience rather than describing its mechanism is answering the wrong question. -**Audience: adapter owners.** This document is deliberately written WITHOUT core internals, because an -adapter owner must be able to answer "can my harness do this?" from public documentation alone. It -describes an interface and a set of obligations, not an implementation. +**Historic audience: adapter owners.** The introduction and §§0–7 below retain the superseded +adapter-design discussion. Their proposed obligations and refusal rules are historical, not the +current contract; §8 replaces them and asks nothing new of adapters. ## 0. The problem in one paragraph @@ -410,21 +410,26 @@ this ruling (a manifest declaration plus a submission id, retracted within the h what core can do with what it ALREADY receives: a session-authenticated USER_INPUT report carrying the exact payload, at whatever moment the harness reports it. -1. **Core binds origin at RECEIPT of every USER_INPUT report** — the session's current authenticated - REMOTE controller seat (the broker-owned `driven_by`). Local seat, viewer, or no seat ⇒ no origin ⇒ - nothing served, named on the declined line. No adapter declares anything; no adapter is asked when it - reports. +1. **Core binds origin at RECEIPT of a path-bearing USER_INPUT report** — the session's current + authenticated REMOTE controller seat (the broker-owned `driven_by`). No quoted paths means no broker + IPC and no line. An absent hosted session, local seat, viewer-only seat, or no controller binds + nothing silently. No adapter declares anything; no adapter is asked when it reports. 2. **Own-injection exclusion.** Core delivers peer messages by writing into the session's PTY itself. A - USER_INPUT whose payload is a delivery core wrote into that session binds no origin — recognised by - the bytes core wrote, never by payload shape. This is core's origin signal for the one injection + USER_INPUT whose payload matches a delivery core physically wrote into that session binds no origin. + Comparison is exact bytes OR both byte sequences trimmed of ASCII whitespace at their ends, never + payload-shape parsing or interior normalization. This is core's origin signal for the one injection source it owns; resumed-session re-submission and external automation stay uncharacterised and bind the seat as the submitter rule says. 3. **Dedup is core-side, keyed on the report:** a repeat of the same payload within the same session while its reference is live re-registers nothing, emits no second helper notice, and extends no TTL. No adapter-minted id exists. -4. **Serving semantics unchanged from #17 / ADR-0058 Am.1:** submitter's machine, 24h TTL, audience the - receiving endpoint, helper notice once per submission and path. A path that names no file on the - submitter's machine is SILENTLY SKIPPED (§4.3); every other non-grant is named (§4.7). +4. **Serving semantics from #17 / ADR-0058 Am.1, corrected by Am.2:** submitter's machine, 24h TTL, + audience the receiving endpoint, helper notice once per submission and path. A path that names no + file on the submitter's machine is SILENTLY SKIPPED, with no fallback origin. The ordinary silent + cases in (1) are not authorization failures. CLI broker-connect and unanswered-receipt failures are + silent unless `SPT_PUMP_TRACE` enables diagnostics. Named declines apply only to failures with a + remote controller; the broker's 10-second owner-reply timeout remains named. §4.7's historical + all-but-one refusal rule is superseded. 5. **The stated ceiling, instead of an adapter promise.** The report lags acceptance by whatever the harness's own path costs (claude-spt: 0.5–1.0 s measured, n=3; omp-spt: unmeasured). A cross-operator Take inside that lag attributes the submission to the NEW holder, whose own file is then served to the @@ -440,10 +445,12 @@ admitted two-node field leg. ### Implemented boundaries -- Both `api state` USER_INPUT and `api now-signal --user-input` report before - event fanout, shortform processing, or roster gathering. The broker validates - the harness session, checks its physical delivery-byte evidence, and snapshots - the live remote controller connection. A later Take never changes that origin. +- Both `api state` USER_INPUT and `api now-signal --user-input` check for quoted paths + before broker IPC; a pathless payload produces no receipt request or line. Path-bearing + reports precede event fanout, shortform processing, or roster gathering. Without a + hosted session or remote controller, the broker silently binds nothing. Otherwise it + snapshots the live remote controller connection before validating the harness session + and checking physical delivery-byte evidence. A later Take never changes that origin. `api state` carries the caller's resolved session proof. A capability token can still authorize the state event, but cannot borrow a session ID from disk to mint input provenance. @@ -451,11 +458,12 @@ admitted two-node field leg. derives the audience from its own rc target, never from request-supplied audience text. The existing MSG_OUT-backed WEB ServeFor guard is unchanged. - The receipt helper waits at most 500 ms for acknowledgement, including a stalled - connect/hello/write, not for serving. A same-node report deadline prevents a - worker that outlives that wait from binding to a later seat. This is not a bound - on the entire hook (its other work has separate costs). The broker allows - 10 seconds for the controller reply, then names the unanswered outcome. Owner - work runs off the rc display/input pump. An unanswered request or uncertain + connect/hello/write, not for serving. CLI broker-connect and unanswered-receipt failures + stay silent unless the existing `SPT_PUMP_TRACE` diagnostic gate is enabled. + A same-node report deadline prevents a worker that outlives that wait from binding to + a later seat. This is not a bound on the entire hook (its other work has separate costs). + The broker allows 10 seconds for the controller reply, then names the unanswered outcome. + Owner work runs off the rc display/input pump. An unanswered request or uncertain owner RPC result retains its dedup fence for the possible reference lifetime: a lost reply is not proof that the owner exposed nothing. - Dedup is per harness session and exact payload hash in the broker-held PTY @@ -465,14 +473,31 @@ admitted two-node field leg. The owner reuses a live same-audience input reference without extending it or changing unrelated exposure. Its timestamp is the earlier of receipt time and owner time, so clock skew cannot lengthen either side's 24-hour bound. -- Physical delivery writes retain exact text hashes, not payload shapes. The - source-tagged controller/typeahead and choreography keys are not delivery text. - In-progress or partially failed physical delivery writes make provenance - unavailable by name rather than guessing; no receipt blocks on a PTY writer. - Session-lifetime evidence is never silently evicted (65536 distinct hashes is - the named capacity limit). An identical later human payload is indistinguishable - from these known bytes and is excluded too; external automation and native - harness re-submission remain subject to the receipt-seat ceiling above. -- Missing files produce neither helper notices nor declined diagnostics. - Wrong session, local/viewer/no seat, unknown delivery evidence, queue pressure, - owner refusal, reply timeout, and persistence failure each name their condition. +- Physical delivery writes retain fixed-size streaming evidence for exact bytes and + ASCII-edge-trimmed bytes, not payload shapes or interior-normalized text. Evidence is + published in physical-write order, not when delivery is merely queued. + Source-tagged controller/typeahead and choreography keys are not delivery text. + In-progress or partially failed physical delivery writes make provenance unavailable + by name when a remote controller is seated rather than guessing; no receipt blocks + on a PTY writer. Session-lifetime evidence is never silently evicted (65536 distinct + delivery candidates is the named capacity limit; exact and trimmed evidence share + one candidate quota). A later human payload matching either comparison + is indistinguishable from these known bytes and is excluded too; external automation + and native harness re-submission remain subject to the receipt-seat ceiling above. +- Missing files produce neither helper notices nor declined diagnostics. No quoted paths, + absent hosted sessions, and local/viewer-only/no-controller seats are also silent. + With a remote controller seated, wrong session, unknown delivery evidence, queue + pressure, owner refusal, the broker's owner-reply timeout, and persistence failure + each name their condition. CLI transport diagnostics remain gated by `SPT_PUMP_TRACE`. + +### Pending field acceptance — not executed in the doc/impl/unit lane + +The separately admitted two-node field leg must exercise a remote controller's quoted +path being served only to the receiving agent, and local input not being served. +Its hazard arm must keep a **remote controller seated while core physically delivers +a path-bearing peer message into the receiving session**: the ensuing USER_INPUT +report must cause **no serve**. A viewer-only or empty-seat run cannot prove that arm. +The same field leg must measure the added hook cost; the 500 ms receipt wait bound +is not an observed hook-cost measurement. These obligations remain pending and do +not activate `int` or impose adapter integration work. The historical measurements +in §7 are not execution evidence for this implementation. diff --git a/docs/adr/0058-attachments-are-pull-model.md b/docs/adr/0058-attachments-are-pull-model.md index fc1b1fe6..cfed98fd 100644 --- a/docs/adr/0058-attachments-are-pull-model.md +++ b/docs/adr/0058-attachments-are-pull-model.md @@ -66,12 +66,11 @@ How it composes with this ADR and ADR-0057: - **Guards:** the path must exist on the user's node at signal time; absolute or `~`-rooted paths only (a relative path has no anchor); at most 5 per message; a directory registers a dir entry under the same TTL and audience. -- **Same node vs remote.** User and agent on one node: nothing is registered, the signal says - the path is local and readable. User on a REMOTE node (the case #17 was minted for): the - file lives on the user's node, so registration happens THERE on the agent's behalf — a - cross-node "serve this path for endpoint X, 24h" request authorized by the user's attach - session, riding the same stream family as the proxy (REQ-WEB-CROSS-NODE-PROXY). This is - the "off-node reach-back" #17 always listed as its dependency; it lands after W1. +- **Same node vs remote — historical Am.1 proposal, superseded for USER_INPUT by Am.2.** + Am.1 proposed a local-readable notice without registration for a same-node user, + and a cross-node "serve this path for endpoint X, 24h" request authorized by the + user's attach session for a remote user. Am.2 replaces the local notice with + silence and uses the existing rc stream for receipt-bound USER_INPUT authority. **Ruled (operator, 2026-09-06 ~11:00Z):** a helper-registered file is **reference-served** — the user's live file, edits visible, 404 once deleted — not a snapshot. The user said "look at @@ -82,22 +81,40 @@ fields are per-entry and kind-independent. ## Amendment 2 — receipt-seat authority for USER_INPUT (operator re-cut, 2026-09-13; releases#300) + The core-only ruling in [INPUT-PROVENANCE-CONTRACT §8](../INPUT-PROVENANCE-CONTRACT.md#8-ruling-doyle-2026-09-13-re-cut-the-same-day-on-the-operators-direction--core-only-no-token-nothing-asked-of-adapters) supersedes Amendment 1's local-readable notice and its proposed new WEB request authority for USER_INPUT. Existing MSG_OUT-backed ServeFor authorization remains unchanged. -At receipt of an existing session-authenticated input report, the broker snapshots -its live remote controller connection. Requests and replies ride that same rc -stream; the owner derives the audience from its locally established target. Local, -viewer-only, and absent seats decline by name, without inspecting receiver-side -paths. The reference origin is `user-input:`. The live-reference kind, +For a path-bearing existing session-authenticated input report, the broker snapshots +its live remote controller connection. No quoted paths means no broker IPC and no +line. An absent hosted session, local or viewer-only seat, or no controller binds +nothing silently, without inspecting receiver-side paths. Requests and replies ride +that same rc stream; the owner derives the audience from its locally established +target. The reference origin is `user-input:`. The live-reference kind, one-endpoint audience, bounded 24-hour lifetime, and receiver-side helper record remain the existing mechanisms. +Core excludes peer-delivery text it physically wrote into the session, comparing +exact bytes OR both byte sequences trimmed of ASCII whitespace at their ends. +There is no payload-shape parsing or interior normalization. Fixed-size streaming +evidence is published in physical-write order; unproven writes or exhausted evidence +capacity fail closed rather than evicting evidence or guessing. + Identical payload/session reports cannot renew a live reference or publish another notice. Lost or uncertain replies retain a dedup fence: absence of an answer is not -proof that registration did not happen. Missing files alone are silent; other -non-grants are named. The hook receipt and owner-reply waits are bounded separately. -This is receipt-seat attribution, not reconstructed keystroke provenance; adapters -receive no new integration obligation. +proof that registration did not happen. Missing files are silent, as are the ordinary +non-remote cases above. CLI broker-connect and unanswered-receipt failures are silent +unless `SPT_PUMP_TRACE` enables diagnostics. Named declines apply only to failures +with a remote controller; the broker's 10-second owner-reply timeout remains named. +The hook receipt and owner-reply waits are bounded separately. This is receipt-seat +attribution, not reconstructed keystroke provenance; adapters receive no new +integration obligation. + +The separately admitted two-node field leg remains pending, not executed by the +doc/impl/unit lane. Alongside remote serving and local silence, it must keep a remote +controller seated while core physically delivers a path-bearing peer message into +the session and prove that its input report causes no serve. It must also measure +the added hook cost. These are deferred field obligations, not measured results or +an activation of `int`. diff --git a/traceable-reqs.toml b/traceable-reqs.toml index 9d01b5d1..00b712d4 100644 --- a/traceable-reqs.toml +++ b/traceable-reqs.toml @@ -7602,8 +7602,8 @@ required_stages = ["doc", "impl", "unit", "int"] # ACTIVATED WEBSERVE W2 (todla [[requirements]] id = "REQ-NOW-SIGNAL-FILE-ACCESS-HELPER" -title = "THE FILE_ACCESS_HELPER CATEGORY HANDS AN AGENT THE EXACT `spt fetch` LINE FOR A FILE IT WAS GIVEN, AND NEVER MORE THAN ONCE PER (MESSAGE, PATH) (releases#17, ADR-0058 Amendment 1, operator directive 2026-09-06 widening the category from carries-attachments to user-quoted-a-path). TWO TRIGGERS, ONE OUTPUT SHAPE. (a) A DELIVERED MESSAGE CARRIES ATTACHMENTS: the signal emits one `spt fetch ` line per attachment, taken VERBATIM from the envelope rather than rebuilt, so the line an agent runs is the link the sender minted. (b) A USER'S MESSAGE QUOTES A FILEPATH THAT EXISTS ON THE USER'S NODE: core AUTO-REGISTERS that path as a REFERENCE-SERVED entry -- a file or dir entry, NEVER a snapshot, because the user said look at this and not keep this as it was, and a live reference costs no copy -- with ttl 24h, origin = the message short-ID, and audience = THE ONE ENDPOINT THAT RECEIVED THE MESSAGE (REQ-WEB-ENTRY-AUDIENCE), then hands that endpoint the fetch line. SAME-NODE USER AND AGENT REGISTER NOTHING: the signal says the path is local and readable, because serving a file to a process that can already open it buys an audit entry and no access. A REMOTE USER -- the case #17 was minted for -- has the file on THEIR node, so registration happens THERE on the agent's behalf: a cross-node serve-this-path-for-endpoint-X request authorized by the user's attach session, riding the stream family of REQ-WEB-CROSS-NODE-PROXY, which is why this rider lands after W1. GUARDS, EACH ITS OWN CELL: the path must EXIST on the owning node at signal time (a quoted path that is not there emits NOTHING rather than a dead link); ABSOLUTE OR ~-ROOTED PATHS ONLY, because a relative path has no anchor and would silently name a different file on the other node; AT MOST 5 PER MESSAGE; a directory registers a dir entry under the same ttl and audience. EVERY ENTRY IT MINTS IS ENUMERABLE IN `spt serve list` WITH ITS ORIGIN, so an automatic exposure is exactly as visible as a deliberate one and what-am-I-exposing keeps its single answer. DELTA DISCIPLINE on the standing now-signal rule: once per (message, path), so a re-poll in the same session emits nothing. Gate: doc -- the shells/frames.md now-signal category table and the attachments page's helper section; impl -- the category, the attachment trigger, the quoted-path detector, the auto-registration carrying ttl and audience and origin, the cross-node register-on-my-behalf request; unit -- the attachment trigger's exact emitted line, the quoted-path trigger registering with a 24h ttl and the receiving endpoint as audience, each guard as its own cell (missing path silent, relative path skipped, the cap of 5, a directory registering a dir entry), the same-node case saying local and registering nothing, and the once-per-(message,path) delta holding across a re-poll; int -- a remote user's quoted path served to the named endpoint end to end." -required_stages = ["doc", "impl", "unit", "int"] # ACTIVATED WEBSERVE W2 (todlando build 2026-09-07), releases#272/#17, ADR-0058 Amendment 1. doc = docs-site/src/shells/frames.md now-signal category table + serving/attachments.md helper section. impl = the FILE_ACCESS_HELPER category in crates/spt/src/api/nowsignal.rs, the attachment trigger, the quoted-path detector + guards, the auto-registration (ttl 24h, audience, origin = short-ID), the cross-node register-on-my-behalf request. unit = attachment line verbatim, quoted-path registration fields, each guard cell, same-node local-and-no-entry, once-per-(message,path). int = a remote user's quoted path served to the named endpoint end to end. +title = "THE FILE_ACCESS_HELPER CATEGORY HANDS AN AGENT THE EXACT `spt fetch` LINE FOR A FILE IT WAS GIVEN, WITHOUT REPEATING A NOTICE (releases#17/#300, ADR-0058 Amendments 1 and 2). TWO TRIGGERS, ONE OUTPUT SHAPE. (a) A delivered message carries attachments: emit each fetch line VERBATIM from its envelope, once per (message, path). (b) A path-bearing USER_INPUT report binds the receiving session's live authenticated REMOTE controller at receipt; registration happens on that controller's machine over the same rc stream, with audience derived from its established target. The result is a live file or directory reference, not a snapshot, with a bounded 24h lifetime, origin = user-input:, and audience = the one receiving endpoint. NO QUOTED PATHS means no broker IPC and no line. An absent hosted session, local or viewer-only seat, or no controller binds nothing SILENTLY; the Am.1 same-node readable notice is superseded. Missing files on the bound owner are silent, with no fallback origin. CLI broker-connect and unanswered-receipt failures are silent unless SPT_PUMP_TRACE enables diagnostics. Named declines apply only to failures with a remote controller; the broker's 10-second owner-reply timeout remains named. Absolute or ~-rooted paths only; at most five per report; directories have the same TTL and audience. Core-written physical peer deliveries bind no origin when bytes match exactly OR after trimming ASCII whitespace from both ends of both byte sequences, never by payload-shape parsing or interior normalization. Fixed-size streaming evidence follows physical-write publication order; unproven or capacity-exhausted evidence fails closed. Repeated payload/session reports register nothing again, repeat no helper notice, and never extend a live reference's deadline. Every exposure remains enumerable in `spt serve list` with its origin. No adapter integration obligations. Evidence: the kept attachment-link, receiver-never-resolves, live-reference dedup, missing-file/refusal, and HTTP edit/deletion cells, plus the receipt-path local-silence, no-path/no-transport, and physical-delivery byte-exclusion cells replace the deleted four guard-cell promise. Historical int evidence belongs to the original helper mechanism, not the deferred #300 field leg. That pending leg must prove remote serving and local silence, keep a remote controller seated while core physically delivers a path-bearing peer message whose report must cause NO SERVE, and measure added hook cost; none is claimed executed here." +required_stages = ["doc", "impl", "unit", "int"] # Preserve WEBSERVE W2 activation and historical original-helper int evidence, not #300 field acceptance. doc = serving/attachments.md + ADR-0058 Am.2. impl = nowsignal attachment trigger + core receipt/rc live-reference path. Kept unit evidence: the_attachment_trigger_emits_the_senders_own_link_once; a_report_never_resolves_its_path_on_the_receiving_node; repeated_payload_has_one_notice_and_never_extends_a_live_reference; missing_file_is_silent_but_a_refusal_is_named; input_reference_http_reads_observe_edits_and_deletion. New receipt evidence: input_receipt_local_is_silent_then_remote_stays_bound_across_take; no_path_payload_never_calls_the_receipt_transport; trimmed_harness_resubmission_matches_core_written_path_bearing_peer_message. #300 field obligations remain pending. [[requirements]] id = "REQ-DOCS-CHANGELOG-PAGE" @@ -7637,5 +7637,5 @@ required_stages = [] # NOT ACTIVATED -- doyle 2026-09-10, registry-first per th [[requirements]] id = "REQ-INPUT-PROVENANCE-ACCEPTANCE-REPORT" -title = "A REMOTE SUBMITTER'S QUOTED ABSOLUTE PATHS ARE SERVED FROM THE SUBMITTER'S MACHINE, BOUND BY CORE ALONE AT RECEIPT OF THE USER_INPUT REPORT ADAPTERS ALREADY SEND -- NO TOKEN, NO DECLARATION, NOTHING ASKED OF ANY ADAPTER (releases#300; operator-ruled 2026-09-13 that #300 is developed entirely independently of harness adapters, USER_INPUT being all core asks of them; doyle re-cut docs/INPUT-PROVENANCE-CONTRACT.md section 8 the same day). Operator experience (relayed, comment 5645159945): the origin is the SENDER'S computer; a path that names no file there is SILENTLY SKIPPED, no prompt, no fallback origin. MECHANISM, core-only: (1) at RECEIPT of every USER_INPUT report core binds origin to the session's current authenticated REMOTE controller seat, the broker-owned driven_by; local seat, viewer, or none binds nothing and the declined line names it. (2) OWN-INJECTION EXCLUSION: a report whose payload is a delivery core itself wrote into that session's PTY binds no origin, recognised by the bytes core wrote and never by payload shape; resumed-session and external automation remain uncharacterised and bind the seat per the submitter rule. (3) core-side dedup: a repeat of the same payload in the same session while its reference is live re-registers nothing, emits no second helper notice, extends no TTL. (4) serving unchanged from the live-reference contract: submitter's machine, 24h TTL, audience the receiving endpoint, helper notice once per submission and path; every non-grant named EXCEPT the missing file, silent by decision. STATED CEILING, not an adapter promise: the report lags acceptance by the harness's own path (0.5-1.0 s measured on claude-spt), and a cross-operator Take inside that lag attributes to the new holder, whose own file is then served to the agent that holder controls. Gate: doc -- section 8 of the contract; impl -- the receipt-time bind, the own-injection exclusion, the dedup, wired to the existing live-reference serve; unit -- remote seat binds that seat, viewer/local/none binds nothing with the declined line naming it, a core-written delivery payload binds nothing, a repeated payload is idempotent with no TTL extension, a missing file on the bound origin emits no line; int -- a real two-node rc session where a remote controller's quoted path is served to the agent and a locally typed one is not." -required_stages = ["doc", "impl", "unit"] # releases#300 core-only lane; int activates at the separately admitted field leg. +title = "A REMOTE SUBMITTER'S QUOTED ABSOLUTE OR ~-ROOTED PATHS ARE SERVED FROM THE SUBMITTER'S MACHINE, BOUND BY CORE ALONE AT RECEIPT OF THE EXISTING USER_INPUT REPORT -- NO TOKEN, NO DECLARATION, NOTHING ASKED OF ADAPTERS (releases#300; operator-ruled 2026-09-13; INPUT-PROVENANCE-CONTRACT section 8; ADR-0058 Amendment 2). NO QUOTED PATHS means no broker IPC and no line. For a path-bearing report, core binds origin to the session's current authenticated REMOTE controller seat, the broker-owned driven_by. An absent hosted session, local or viewer-only seat, or no controller binds nothing SILENTLY. OWN-INJECTION EXCLUSION: a report matching a peer delivery core physically wrote into that session's PTY binds no origin, comparing exact bytes OR both byte sequences trimmed of ASCII whitespace at their ends, never payload-shape parsing or interior normalization. Keep fixed-size streaming evidence, physical-write publication order, and fail-closed unproven/capacity handling. Resumed-session and external automation remain uncharacterised and bind the seat per the submitter rule. Core-side dedup: a repeated payload in the same session while its reference is live re-registers nothing, emits no second helper notice, extends no TTL. Serving uses the submitter's machine, bounded 24h TTL, audience the receiving endpoint, and one helper notice per submission and path. Missing files are silently skipped, with no prompt or fallback origin. CLI broker-connect and unanswered-receipt failures are silent unless SPT_PUMP_TRACE enables diagnostics; named declines apply only to failures with a remote controller, including the broker's 10-second owner-reply timeout. STATED CEILING, not an adapter promise: reports lag acceptance by the harness's own path (historical claude-spt measurement 0.5-1.0 s), and a cross-operator Take inside that lag attributes to the new holder. Gate: doc -- section 8 and ADR-0058 Am.2; impl -- receipt-time binding, physical-delivery exclusion, dedup, existing live-reference serving; unit -- remote binding survives Take, local/viewer/none/absent session bind nothing silently, no paths never call receipt transport, exact and ASCII-edge-trimmed core-written deliveries bind nothing without interior normalization, repeated payloads cannot renew TTL or repeat notices, missing files emit no line. The separately admitted two-node field leg is PENDING: remote controller's quoted path serves only to the receiving agent; local input does not serve; with a remote controller seated, a path-bearing peer message physically delivered by core must cause NO SERVE on its input report; measure added hook cost. No execution or hook-cost result is claimed and no adapter integration work is required." +required_stages = ["doc", "impl", "unit"] # releases#300 core-only lane; int remains deferred until the separately admitted field leg, including remote-seated physical peer-delivery exclusion and added hook-cost measurement.