feat(routing): peer-side CommandRequestHandler + execute_with_caller threading#1529
Merged
Conversation
Initial design doc for the universal addressing primitive every subsequent substrate slice rides on. Lands BEFORE A.2.2 (which adds `runtime/mode/*` commands needing grid addressability from line one) and BEFORE B' (every `CategorySidecar` exposes `category/<name>/*` command surfaces requiring uniform addressing). ## What this doc establishes The substrate provides exactly two universal primitives — Commands.execute() + Events.subscribe()/emit(). For those to scale across 14 personas × multiple nodes × multiple grids × N concurrent embodiments per actor, there has to be ONE addressing primitive every consumer reaches through. This doc defines it. ## URI grammar (RFC 3986-aligned) ``` airc://[peer[@node]][:env]/[path][?query][#fragment] ``` Four load-bearing axes: - **Identity** (peer) — cryptographic peer_id (name resolves via whois) - **Locality** (@node) — node-id for peer-at-node disambiguation - **Embodiment** (:env) — presentation context (web, vr, tty, ar, *) - **Substrate** (/path) — command path OR persona-internal substructure Examples: - `inference/llm/generate` — implicit local - `airc://maya/inference/llm/generate` — peer by name - `airc://maya@5090-rig:vr/scene/spawn` — peer-at-node:env - `airc://maya:*/notification/post` — broadcast to all envs - `airc://room:cb2e21a1:web/render/start` — room broadcast, web only - `airc://maya/cognition/genome/lora:typescript-expertise/` - `airc://maya/cognition/working-set/` - `airc://maya/state/mood` - `airc://maya/turn/current` — streaming HandleRef ## "You can take it to any node" The compression payoff Joel emphasized: same URI works whether Maya is local, on the 5090, or migrated mid-call. The dispatcher resolves peer locality at call time; the caller doesn't see the move. Same for the persona-internal namespace — `airc://maya/state/mood/get` works from the laptop while Maya cognizes on the M5. ## Transport selection is the substrate's job, not the URI's ONE URI grammar. The substrate picks Unix-socket / Tailscale-direct / Reticulum / public-mesh based on peer locality. Inside/outside (Tailscale subnet vs. public mesh) is transport selection, not URI variation. ## Auth gate at the substrate boundary Every URI has a typed `(caller_peer_id, uri) → Verdict` gate. `Verdict::{Allowed, Forbidden{reason}, Deferred{prompt_target_env}}`. Policy lives in ORM entities per [[no-sql-everything-through-orm-entities]]. Every verdict captured by CaptureSink per [[observability-is-half-the-architecture]]. ## What lands in Slice P 1. `CommandUri` typed enum + RFC 3986 parser/generator (round-trips) 2. `Commands.execute(uri_or_path, params)` accepts both forms 3. Routing function `route(uri) → TransportDispatch` 4. Auth gate at substrate boundary, typed `(caller, uri) → Verdict` 5. HandleRef marshaling across the wire; URI-addressable handles 6. `EnvironmentId` registry — well-known + custom + open-ended 7. `Context::environment()` accessor 8. `env/register` + `env/unregister` commands 9. `event-topic:` URI scheme path for addressable subscriptions 10. THIS doc, evolved in-place as the design crystallizes ## What does NOT land in Slice P - Implementation of every persona-internal namespace (those land per category in B'.X sub-slices) - Actual VR/AR/TUI clients (each is its own slice on `:env`) - Ares-the-dispatcher's cognition (own card, consumes Slice P) - Auth policy DSL (Slice P establishes the gate point + Verdict) - Default policy curation (substrate ships empty; operator seed-time config populates baseline) ## Open questions captured The doc lists 5 design choices that don't block landing but need resolution as we implement: 1. Name-collision policy across continuums 2. HandleRef expiry across migrations 3. Env namespace ownership (substrate-fixed vs. operator-extensible) 4. Wildcard semantics for broadcast (dispatch-time vs. persistent) 5. Cycle detection in cross-grid routing Each has a current-lean noted; the lean will harden as the implementation pins down test requirements. ## Doctrine alignment - [[continuum-thesis-airc-is-the-medium]] — airc:// IS the universal addressing space - [[airc-headers-are-the-routing-layer]] — realized at the URI layer - [[commands-are-kernel-level-and-compose]] — local + remote share dispatcher logic - [[host-the-seemingly-impossible]] — 14 personas × N substructures × M envs × P nodes uniformly addressable - [[observability-is-half-the-architecture]] — every URI fetch CaptureSink-able - [[constitutional-design-always-a-next-step]] — every URI gated; constitutional layer rides on this - [[no-fallbacks-ever]] — URI parse failure / unknown peer / unknown env produce typed Verdict::Forbidden, never silent substitution - [[init-once-handle-then-lease-zero-copy-refs]] — HandleRef wire-stable - [[test-fixtures-are-system-primitives]] — StubRouter + StubEnvRegistry are first-class test primitives ## Card Slice P: `fa25de80-0c1b-4de5-8ff9-524d95e303cd` Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… layer) Once every load-bearing operation has a URI, the SAME identifier becomes the span tag in tracing::Span. debug!(), info!(), etc. inside a dispatched URI's scope inherit the URI tag automatically. Per-persona log segregation, per-env filtering, cross-grid trace correlation — all fall out of one structured field. No special-case "this log belongs to Maya" code; the span context carries it. And the substrate's jtag CLI gets its hardware-namesake's literal semantics: arbitrary-depth structured access to any URI in any persona internal address space from any node: $ ./jtag airc://maya/debug/trace/stream # live tail $ ./jtag airc://5090-rig/debug/sidecar/inference/lane:3/dump $ ./jtag airc://maya/debug/breakpoint/set?uri=cognition/genome/lora-page-in Same primitive used at THREE consumption points: - addressing (where to dispatch) - observability (what to tag) - debugging (what to poke) No three drifting representations. Compression principle compounding once more — the URI grammar Slice P establishes already pays for the next layer of substrate capability before that layer is even been built. Ares-the-dispatcher consumes the SAME trace stream the operator does. When her cognition asks "why did lane 3 stall on the 5090" she queries airc://5090-rig/debug/sidecar/inference/lane:3/dump with the same URI surface a human would. Cognition + operator share the debug primitive. Slice P deliverables 10 + 11 added: 10. Tracing-span URI propagation 11. /debug/ namespace under every URI scope card: fa25de80-0c1b-4de5-8ff9-524d95e303cd Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Joel's historical context (2026-06-04): the previous-generation
continuum had segregated logging, configurable per-node, on/off,
levels, AND a probe macro that worked nicely — but coverage was
never universal. Every new module required remembering to register
the logger, wire the level config, route the probe. Drift class.
The URI substrate brings both primitives back with coverage
structurally enforced — there is no separate config surface to
remember, the URI dispatcher IS the wiring.
## probe! as a first-class substrate operation
`debug!` vs `probe!` contract distinction:
- debug! — freeform messages for human-readable trace tail
- probe! — structured measurements for ALWAYS-ON dashboards,
replay, training signals, SLO breach detection
probe!(latency, turn_id = id, duration_ms = elapsed);
probe!(decision, action = "evict-lora", target = "typescript-expertise");
probe!(state, working_set_size = ws.len());
probe!(admission, lane = 3, verdict = "accepted", caller_uri = %caller);
First arg = class = routing key. Routes to:
airc://<actor>/debug/probes/<class>/stream
Independent subscribers:
- sentinels → latency for SLO breach detection
- Ares → decision + admission as training signal for her
dispatcher cognition
- foundry → whichever class its recipe currently optimizes
- operator → ./jtag airc://maya/debug/probes/decision/stream
during incident
## Configurable log levels + probe enables, URI-addressable
airc://maya/debug/log/level/set?level=debug # persona only
airc://5090-rig/debug/log/level/set?level=warn # node-wide
airc://maya:vr/debug/log/level/set?level=trace # env-scoped
airc://maya/debug/probes/latency/enable
airc://maya/debug/probes/<class>/sample-rate/set?rate=0.1
airc://*/debug/log/redirect?sink=file:/tmp/cluster.log
airc://*/debug/probes/decision/redirect?sink=ares://training-corpus
Previous system's "configurable but never universally wired" failure
mode disappears because there is no separate config surface.
## Slice P deliverables 12 + 13 added
12. probe! macro + per-class probe streams
13. Configurable log levels + probe enables via URIs
## Doctrine
- Joel's compression principle: ONE primitive (URI), N consumers
(addressing, tracing, debugging, probes, log config) — all
routed through the same dispatcher
- [[observability-is-half-the-architecture]] — probes ARE the
always-on structured capture this doctrine demands
- [[no-fallbacks-ever]] — log level / probe enable commands return
typed Verdict; auth gate refuses ambiguous adjustments
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… added for special features
Joel correction (2026-06-04): "You can't burden fast development so we
use conventional macros and add any necessary, like probe for instance
if it has special features."
The earlier framing (probe! as universal-emit substitute for println!)
overcorrected. Right framing:
- Conventional Rust tracing macros (debug!, info!, warn!, error!,
trace!) stay exactly conventional. They get URI-tagged span
inheritance for free, but their NAMES stay what every Rust dev
already reaches for.
- println! / eprintln! stay for quick dev printf — no replacement
pressure.
- probe! is ADDED specifically because it has features the
conventional macros do not, and is reached for ONLY when those
features are needed.
No mental tax. Developer wants a debug log → debug!. Wants quick
inspection → println!. Wants a structured probe with the substrate's
probe-stream contract → probe!. Each tool does its named job; nothing
gets renamed or substituted away from what muscle memory expects.
probe!'s special features (the justification for its existence):
1. Per-class routing (airc://<actor>/debug/probes/<class>/stream)
2. ALWAYS-ON intent for production deployment
3. Replay-persisted to per-class log
4. Sample-rate configurable per class
5. Aggregation-ready for rolled-up stats
If you only want a log line, debug! is the right tool. Use probe!
when you want measurement-style emission with monitoring + replay +
training-signal availability.
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tives
Joel (2026-06-04): "Timing is another super nice one. Make it
inherent and even automatic. Same goes for stacks. Or it's a
timing macro call. Make it easy. This is what mechanics (you
and me, personas) want."
The substrate is for whoever is under the hood — operator,
persona-debugging-itself, Ares diagnosing her own decisions,
sentinel tracing an SLO breach. Timing + stack ancestry are
the two things any mechanic reaches for first when something
is wrong, so they should not require thinking.
## Three primitives added
### Automatic span timing
Every entered tracing::Span records duration on exit. Substrate
subscriber routes to:
airc://<actor>/debug/probes/timing/stream
No code at the call site — the span machinery already does the
bookkeeping; the substrate just wires the emission to the timing
probe stream when a subscriber is listening (zero-cost otherwise).
let _span = tracing::info_span!("recall_phase", turn_id = %id).entered();
let candidates = recall_candidates(&query);
// timing probe emitted automatically at scope end
### time! macro
For when you want a tighter scope than a full span (block /
expression):
time!("recall_phase", { let candidates = recall_candidates(&query); candidates });
let candidates = time!("recall_phase", || recall_candidates(&query));
Same routing as automatic span timing. Zero-cost when timing class
disabled.
### stack! macro
Returns the span ancestry from root to here — the substrate's
"call stack" as URI scopes:
let stack = stack!();
// [ airc://maya/service_loop,
// airc://maya/cognition/turn:144036023249865,
// airc://maya/cognition/recall/algorithm-4 ]
probe!(class = "error", stack = %stack!(), "engram lookup failed");
The structured equivalent of Backtrace::capture() for normal
execution paths. Native stack capture remains Rust's existing
Backtrace API; stack! is the substrate's URI-tree analog.
## Derived views are URI-addressable rollups, not separate systems
Flamegraphs and CPU profiles are renderings of the timing + span-
tree data the substrate is ALREADY emitting:
airc://maya/debug/profile/flamegraph?window=5m
airc://maya/debug/profile/flamegraph?window=5m&format=svg
airc://5090-rig/debug/profile/cpu?window=30s # pprof
airc://maya/debug/profile/spans-tree?turn_id=144036023249865
Same dispatcher, same auth gate, same observability. No new
instrumentation system — same probe stream rolled up into a
renderable shape.
## URIs for live span introspection
airc://maya/debug/spans/active # currently entered + elapsed
airc://maya/debug/spans/<span_id>/ancestry
airc://maya/debug/spans/<span_id>/duration
airc://maya/debug/spans/<span_id>/inspect
airc://maya/debug/spans/by-uri?path=cognition/recall
Operator running `./jtag airc://maya/debug/spans/active` during an
incident sees exactly what Maya is doing right now — span tree
with elapsed times, no manual instrumentation, no log-grepping.
## Why this is mechanic-grade
A mechanic (operator, persona, Ares) wants three things first:
1. How long is this taking? → automatic span timing
2. Where did it come from? → stack! / span ancestry
3. What's running right now? → /debug/spans/active
All three reachable without instrumenting code. The substrate's
existing span machinery already knows the answers; Slice P makes
the answers addressable.
## Slice P deliverables 14–17 added
14. Automatic span timing → /debug/probes/timing/stream
15. time! macro (explicit block timing)
16. stack! macro (span ancestry as Vec<SpanFrame>)
17. Derived views (flamegraph / CPU profile / span-tree rollups) via URI
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…pline
Joel (2026-06-04) layered three more compression payoffs onto the
URI grammar in successive messages:
1. RBAC + integrity: URI IS the policy unit. ONE chokepoint,
ONE grammar, N consumers (Ares dispatch, sentinel audit,
operator allowlist, cross-grid hostile traffic filter).
Policy keyed against the same CommandUri grammar everything
else uses. Cross-grid hostile traffic gets typed Verdict
refusal by default.
2. Macros-as-misuse-prevention: "if coding timing or logging is
painful it won't happen." The ergonomic discipline IS the
security discipline. Easy macros -> universal instrumentation
-> observable substrate -> findable problems.
3. Compiler collaboration: "macros are always used [-> debugging]
can be completely removed, not even a no op, not there. We
give advantages to compilers if we are consistent." Per-class
probe features compile OUT entirely at LTO time -- not down
to no-ops, literally absent from the binary.
## What the doc now captures
### RBAC keyed against the same grammar
- Policy is (caller_peer_id, uri_pattern) -> Verdict
- ONE substrate-boundary chokepoint (envelope verify -> URI parse ->
policy match -> Verdict). New commands inherit coverage on
registration; "did we remember to gate this?" stops being a
question.
- Cross-grid AnonymousExternal callers default to chat-only;
persona-internal namespace + /debug/ require explicit per-URI
grants. Same primitive, different verdict, no parallel surface
to forget.
### Concrete near-term use: persona latency debugging
Joel: "We will be using this to debug our persona, especially for
latency. And soon." Doc names how:
- automatic span timing on airc://maya/cognition/turn:*
- /debug/profile/flamegraph rollup
- /debug/probes/timing/stream live tail
- /debug/spans/active for "what's blocking right now"
- stack!() in probes
### Compiler-strip design discipline
Five-rule contract that lets rustc eliminate disabled macros at
LTO time:
1. Always expand to the same shape
2. Gated by compile-time consts only (no runtime atomics)
3. No allocations in the expansion
4. No dynamic dispatch in the gate
5. Consistent with tracing::* shapes
When macros respect this contract they are NOT there in disabled
builds -- zero text-segment bytes, zero branch slots.
Three deployment tiers from one source tree:
- operator's full-observability build (all probe classes on)
- edge-device build (probes-all off)
- forensic-investigation build (probes-all on)
## Doctrine alignment
- Joel's compression principle: URI = address = audit unit =
permission unit = debug unit (same primitive, N consumers)
- [[no-fallbacks-ever]]: unknown peer / unmatched URI = typed
Verdict::Forbidden, never silent permit
- [[observability-is-half-the-architecture]]: every URI dispatch +
verdict captured by CaptureSink
- [[no-sql-everything-through-orm-entities]]: policy rows in ORM,
not bespoke YAML/TOML
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…d-trip
First code commit on Slice P — locks the URI grammar the design doc
established. Subsequent commits add dispatcher hooks, transport
selection, auth gate, and the macros that ride on this primitive.
## What this commit adds
src/workers/continuum-core/src/routing/
mod.rs # module entry, public re-exports
command_uri.rs # types, parser, Display, tests
## CommandUri typed enum
Four exhaustive variants partition by what's being addressed:
- Local { path, query, fragment }
bare paths AND airc:///path
- Peer { peer, node, env, path, query, fragment }
peer-addressed dispatch with optional node and env
- Room { room_id, env, path, query, fragment }
room broadcast, optional env filter
- Broadcast { peer, node, path, query, fragment }
peer with wildcard env (:* form) — kept distinct
so the dispatcher can pick a fan-out transport
PeerRef: Uuid(Uuid) | Name(String)
NodeId: opaque String
EnvSelector: Named(String) | Wildcard
## Round-trip guarantee
For every valid input, CommandUri::parse(&uri.to_string()) ==
Ok(uri.clone()). Pinned by 14 round_trip tests covering every
variant + every combination of optional fields.
## Reserved sigil
"room:" prefix on a URI authority parses to CommandUri::Room. A
peer literally named "room" must use the canonical UUID form. The
collision is documented in the design doc and enforced at parse
time.
## Tests — 31 unit tests, all pass
Coverage:
- bare path -> Local (with/without query/fragment)
- airc:///path -> Local
- airc://name/path -> Peer{Name}
- airc://uuid/path -> Peer{Uuid}
- airc://name@node/path -> Peer with node
- airc://name:env/path -> Peer with env
- airc://name@node:env/path -> Peer with both
- airc://name:*/path -> Broadcast
- airc://name@node:*/path -> Broadcast with node
- airc://room:uuid/path -> Room
- airc://room:uuid:env/path -> Room with env
- persona-internal paths (cognition/genome/lora:...)
- debug-namespace paths
- query + fragment on every variant
- error cases: unknown scheme, empty input, empty path,
invalid room UUID, empty room UUID, empty node id,
empty env, malformed peer UUID
- name-vs-UUID detection (hyphenated names do NOT false-positive
as UUIDs; segment-shaped non-hex strings DO produce
InvalidPeerUuid)
Test result: 31 passed; 0 failed; finished in 0.06s
## Naming + ergonomics
use continuum_core::routing::CommandUri;
// Bare path (existing call sites work unchanged):
let uri: CommandUri = "inference/llm/generate".parse()?;
// Fully qualified:
let uri: CommandUri =
"airc://maya@5090-rig:vr/scene/spawn".parse()?;
// Round-trips to canonical form:
assert_eq!(uri.to_string(),
"airc://maya@5090-rig:vr/scene/spawn");
## What's NOT in this commit (lands in subsequent ones on this branch)
- Commands.execute() accepting CommandUri (dispatcher integration)
- route(uri) -> TransportDispatch (transport selection)
- Auth gate Verdict type
- probe! / time! / stack! macros
- EnvironmentId registry
- /debug/ namespace routing
- Context::environment() accessor
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…igration shim)
Joel's call (2026-06-04): "Don't worry about backwards compatibility,
just fix the callers one at a time." Then immediately, after seeing
the 21 mismatched-types errors my first cut produced: "Yeah then
phase out each str, then delete, unless you want a translator
str -> into commandparams better right? Then it's automatic and
backwards compatible."
The "translator" idea is the cleaner shape. `impl From<&str> for
CommandUri` + `impl Into<CommandUri>` parameter type means EVERY
existing `execute("path", params)` call site keeps compiling
unchanged AND new code can pass typed `CommandUri::Peer { ... }`
explicitly when it wants the URI grammar's full expressiveness.
## What this commit adds to routing/
CommandUri::local("path") # infallible constructor for Local
CommandUri::path() -> &str # unified path accessor
CommandUri::is_local() -> bool # short-circuit accessor for dispatcher
impl From<&str> for CommandUri # auto-coerce bare paths
impl From<String> for CommandUri
impl From<&String> for CommandUri
## Executor migration
pub async fn execute(
&self,
command: impl Into<CommandUri>, # was: command: &str
params: Value,
) -> Result<CommandResult, String>
Same shape for execute_json, execute_ts, execute_ts_json, and the
top-level free function re-exports under runtime::*.
Internally, `execute` collapses to one of two paths:
- Local URI -> existing dispatch chain (interceptors -> Rust module
registry -> TS bridge), unchanged
- Peer/Room/Broadcast -> typed not-yet-implemented error pending
the transport selector (subsequent Slice P commit). The CommandUri
Display impl renders cleanly in the error message so the operator
sees the URI shape they passed.
The interceptor trait (CommandInterceptor::try_route) still operates
on `&str` paths internally — that's a deliberate scope choice to
keep this commit's blast radius bounded. The interceptor trait
migration is a follow-up commit on this branch.
## Call-site migration result
Before: 21 type-mismatch errors at every existing
execute("path", params) site
After: zero touches to existing call sites; they auto-coerce via
the From<&str> shim
New code: passes CommandUri::local(...) or any other variant
explicitly when it wants grammar awareness
## Test rigging
`routing::command_uri` tests grew from 31 to 37:
+ from_str_shim_accepts_bare_path
+ from_str_shim_accepts_full_uri
+ from_str_shim_falls_back_to_local_on_parse_error
+ from_string_shim_works_for_owned_strings
+ local_constructor_is_infallible_and_round_trips
+ is_local_polarity (every variant checked)
The shim's "parse failure falls back to Local, not a parse error"
semantics is locked: the boundary is permissive (any string converts);
the dispatcher's handler-not-found path is where the typed error
surfaces, not the URI parse itself. This keeps the migration painless
while preserving correctness — a malformed call still fails, just
with a downstream typed error instead of a parse error at the seam.
## What's NOT in this commit (lands in subsequent Slice P commits)
- CommandInterceptor::try_route migration to CommandUri
- route(uri) -> TransportDispatch (the actual transport selector)
- Auth gate Verdict type
- probe! / time! / stack! macros
- EnvironmentId registry
- /debug/ namespace routing
- Context::environment() accessor
## Doctrine alignment
- Joel's compression principle: ONE URI grammar at the substrate
boundary; existing call sites benefit without explicit migration
- Joel's "macros / shims are misuse prevention": the From shim
means developers can't accidentally bypass the typed primitive
by passing strings — every path through execute() goes through
CommandUri at the type level
- [[no-fallbacks-ever]]: non-Local URIs produce typed errors,
not silent substitution
- "Be ridiculously fast": the shim is zero-cost — &str -> Into ->
From -> Local { path: s.to_string(), .. } at the seam, then the
rest of dispatch is identical to before
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three substrate primitives wired in one commit because they share
the same tracing seam:
## Dispatch span on every command
`CommandExecutor::dispatch` now establishes a `tracing::info_span!`
tagged with `uri = %command, path = %command.path()` for every
dispatched command. Every `debug!` / `info!` / `probe!` / `time!`
event inside the dispatched command inherits the URI tag via
tracing's span context.
The "per-persona log segregation, cross-grid trace correlation,
URI-routed observability all fall out of one structured field"
claim from the design doc is now load-bearing in code: there is
ONE seam (the dispatch span), every consumer downstream tags
automatically, no per-call-site instrumentation needed.
## `probe!` macro — structured measurement emit
use continuum_core::probe;
probe!(class = "latency", turn_id = %id, duration_ms = elapsed, "turn complete");
probe!(class = "decision", action = "evict-lora", reason = "lru");
probe!(class = "state", working_set_size = ws.len());
probe!(class = "admission", lane = 3, verdict = "accepted", "admitted");
The macro expands to `tracing::event!(Level::INFO, probe_class = $class, $fields..)`.
Routing key is the `probe_class` field; a tracing Layer that
filters on it routes the event to
`airc://<actor>/debug/probes/<class>/stream` (the subscriber Layer
lands in a follow-up commit on this branch).
## `time!` macro — explicit-block timing
let candidates = time!("recall_phase", {
recall_candidates(&query)
});
let result = time!("inference_run", run_inference(&model, &prompt));
Wraps a block or expression in an `info_span!` whose duration
becomes a timing probe at scope exit. The span carries
`probe_class = "timing"` and `name = $name`; the same subscriber
that routes `probe!` events handles span durations via
`airc://<actor>/debug/probes/timing/stream`.
## Compiler-strip discipline maintained
Both macros expand to `tracing::*` calls that inherit tracing's
`release_max_level_*` cargo feature gates. When the level is
filtered out at build time, the expansion is literally absent
from the binary — no formatting, no allocation, no branch. The
five-rule contract from the design doc (same shape, compile-time
gate, no alloc, no dynamic dispatch, tracing idiom) is
satisfied: the macros expand to one event/span call each, no
runtime polymorphism, no allocation outside the format-string
path that the level filter already gates.
## Convention preserved
`debug!`, `info!`, `warn!`, `error!`, `trace!` stay exactly the
conventional Rust tracing macros. `probe!` is reached for ONLY
when its special features (per-class routing, ALWAYS-ON intent,
replay persistence, sample-rate, aggregation) are wanted.
Developers aren't asked to learn new ergonomics or pick between
similar-looking tools for the same job.
## Tests
7 new tests in `routing::macros` lock the call surface:
- probe with class and message only
- probe with class + structured fields + message
- probe class="decision" with action/target/reason fields
- probe class="state" with shorthand field syntax
- time! block form returns block value
- time! expression form returns expression value
- nested time! inside time! inside probe! (mechanic pattern)
The tests verify the macros COMPILE and PARSE correctly with every
documented call shape. The subscriber-routing layer (which lands
in a follow-up commit) is what makes the events actually route to
URI streams; this commit locks the emission contract.
## What's NOT in this commit (next Slice P commits)
- Per-class subscriber Layer (routes probe_class events to
URI streams)
- `airc://<actor>/debug/probes/<class>/stream` command
(consumer side of the streams)
- `airc://<actor>/debug/profile/flamegraph` URI (rollup over
timing stream)
- `stack!` macro (span ancestry as Vec<SpanFrame>)
- Transport selector (Local-only dispatch today; Peer/Room/Broadcast
return typed not-yet-implemented)
- Auth gate Verdict type
- EnvironmentId registry
## Doctrine alignment
- Compression principle: one tracing seam, N consumers
(logs, probes, timing, debug streams)
- "Macros are easy and a way of preventing misuse" — same
shape as `tracing::*`; muscle memory transfers
- "Macros compile OUT, not down to no-ops" — release-mode
builds with level filters strip the expansion entirely
- [[no-fallbacks-ever]] — probe class is a structured field,
not a runtime-stringly-typed lookup; misuse surfaces as a
type error or compile failure, not silent wrong-routing
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two more typed substrate primitives in the same compression-principle
shape every other Slice P type uses: exhaustive variants, Display
round-trip, From<&str> migration shim where useful, `.kind()` short
labels for audit logs.
## Verdict — the auth gate's typed result
```rust
pub enum Verdict {
Allowed,
Forbidden { reason: ForbiddenReason },
Deferred { reason: DeferredReason, prompt_target_env: EnvironmentId },
}
```
`ForbiddenReason` variants:
- UnknownPeer (cross-grid stranger, no enrollment)
- NoPermissionForUri(String) ← carries the URI for the audit row
- AdmissionDenied(String) ← rate limit, pressure broker, etc.
- Revoked ← had permission, was withdrawn
`DeferredReason` variants:
- AskTargetEnv ← route a consent prompt to the target's primary env
- SentinelQuorum { required: u32 }
This is the typed seam every substrate-boundary dispatch will
consult. ONE chokepoint, ONE typed result, exhaustive matching at
every consumer. The audit row knows WHY a request was denied
(unknown peer vs. no URI grant vs. admission denial vs. revocation);
the operator's debug experience is one match, not four.
## EnvironmentId — the typed embodiment dimension
```rust
pub enum EnvironmentId {
Named(String), // "web", "tty", "vr", "ar", "cli", "headless", or custom
Wildcard, // matches every active env on the target
}
pub enum WellKnownEnv {
Web, Tty, Cli, Vr, Ar, Headless,
}
```
`WellKnownEnv::all()` returns the canonical list (useful for the
forthcoming env registry to seed itself and for tests). `From<&str>`
auto-coerces strings — `"*"` becomes Wildcard, anything else becomes
Named, mirroring the `From<&str> for CommandUri` migration pattern.
Custom envs slot in via `Named(String)` with the design doc's
recommended `x-<vendor>-<name>` prefix convention. Substrate doesn't
need code changes to add a new env name (e.g. for a Bevy 3D HUD,
a new console interface, etc.).
## Same compression-principle pattern, again
Each typed primitive in this session follows the same shape:
- Exhaustive enum (no fallthrough)
- Display round-trip via canonical string form
- From<&str> / From<other_type> migration shims where ergonomic
- .kind() / .is_*() accessors for hot-path branches
- Tests cover round-trip + every variant + drift catchers
BootMode, AircDiscovery, CommandUri, Verdict, EnvironmentId — same
shape. The meta-pattern Joel named in our discussion is now visibly
load-bearing across five typed primitives. A future
`derive(SubstratePrimitive)` macro becomes credible scope once we
have 7-8 of them and the boilerplate is the cost; for now,
hand-rolled with deliberate uniformity is fine.
## Tests
Routing module total: 58/58 pass (was 44):
- verdict: 5 new tests (each variant, NoPermissionForUri carries
URI in display, AdmissionDenied carries cause)
- environment: 9 new tests (Named round-trip, Wildcard round-trip,
From<&str> for both wildcard + named + custom prefix,
WellKnownEnv canonical names, well-known -> EnvironmentId
round-trip, hashability for registry use)
## What's NOT in this commit
- Auth gate function `policy(caller, uri) -> Verdict` (the policy
matching logic; lands when the policy ORM entity is added)
- EnvironmentId registry (which envs are active per actor; lands
when env/register command is added)
- Substrate boundary code that calls the gate (the executor's
dispatch site; integration once the policy function exists)
- URI parser hooking these types in (today CommandUri uses its
own EnvSelector type; consolidation lands when the parser is
updated to produce EnvironmentId directly — small follow-up)
## Doctrine alignment
- Compression principle: every typed substrate primitive in the
same shape; future ones inherit the pattern
- [[no-fallbacks-ever]]: Verdict::Forbidden is the ONLY denial
path; never a silent permission grant; reason is typed not
string-stringly
- "Abstraction is how complex systems become manageable":
Verdict + EnvironmentId give every future security/env consumer
a uniform surface to match exhaustively
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…yped primitive, one source of truth
Two commits ago the URI parser introduced `EnvSelector` (Named /
Wildcard) for the `:env` URI component. The next commit introduced
`EnvironmentId` (Named / Wildcard) for the env registry + Context
accessor. They were duplicates — same variants, same semantics,
same Display contract — sitting in parallel because each commit
addressed a different consumer and neither knew the other was about
to land.
The compression principle says: when two types describe the same
concept, they're not "decoupled" — they're drift waiting to happen.
This commit merges them.
## What changes
- `routing::command_uri::EnvSelector` deleted
- `CommandUri::Peer { env, .. }` and `CommandUri::Room { env, .. }`
now hold `Option<EnvironmentId>` directly
- URI parser's `parse_env_selector` delegates to
`EnvironmentId::from(&str)` — the substrate has ONE place that
decides what an env-string means
- Duplicate `impl Display for EnvSelector` removed (the Display
contract lives in `routing/environment.rs` only)
- `pub use` in `routing/mod.rs` drops `EnvSelector` from the
re-exports
## What stays the same
- Every behavior the URI parser supported (`:web`, `:*`,
`:vr`, etc.) still works
- Every URI grammar test still passes (round-trips, parse, Display,
error cases) — see "58/58 routing tests pass" below
- `From<&str>` and `From<String>` migration shims unchanged
- Every existing call site keeps compiling without touch
## Why this is the substrate getting simpler while gaining more
Joel observed (2026-06-04): "See how it is getting simpler while
maintaining the same, if not more, capabilities?"
Same line count net (a few lines deleted, doc-comments adjusted),
ONE fewer typed primitive in the routing surface area, AND every
future consumer that needs "what env does this URI target" now
reaches the same primitive that:
- `env/register` / `env/unregister` commands will match against
- `Context::environment()` accessor will return
- Transport selection will dispatch on
- `Verdict::Deferred { prompt_target_env }` consent prompts will
target
Five future features just got cheaper to build because the primitive
is shared. That's the property the session has been chasing: the
substrate's marginal cost of adding the next feature goes DOWN over
time, because every new feature slots into existing typed primitives
instead of inventing parallel ones.
## Tests: 58/58 routing tests pass (unchanged)
The consolidation preserves every existing behavior — every test
that exercised `EnvSelector::*` now exercises `EnvironmentId::*`
via the same call shape. The count is unchanged because no tests
were dropped (one fewer type does not mean fewer behaviors to lock).
## Doctrine alignment
- Compression principle: when two types describe the same concept,
merge them; don't carry duplicates because "they happened in
different commits"
- Joel's meta-pattern: see repetition (two parallel types) → ask
what greater pattern they're instances of (one typed embodiment
primitive) → formulate the more elegant abstraction
(consolidate, share, fewer types more reach)
- "Abstraction is how complex systems become manageable" — fewer
primitives, more consumers per primitive, complexity bounded by
primitive count rather than consumer × variation cross-product
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Completes the mechanic-grade observability trio established in
earlier Slice P commits:
How long is this taking? → automatic span timing + time! macro ✓
Where did it come from? → stack! macro (this commit) ✓
What's running right now? → tracing::Span::current() / debug surface ✓
Per Joel: "this is what mechanics (you and me, personas) want."
## Shape
use continuum_core::{stack, probe};
// Typical pattern — attach the URI stack to an error probe so the
// operator watching /debug/probes/error/stream sees the chain of
// dispatches that led to the failure
probe!(class = "error", stack = ?stack!(), "engram lookup failed");
// Standalone — capture the current substrate scope
let frames: Vec<String> = stack!();
## Current implementation
This commit ships the SINGLE-FRAME form: returns
`vec![current_span_name]` from `tracing::Span::current()`. The macro
shape doesn't change when the multi-frame ancestry walk lands with
the URI-aware tracing Layer (follow-up commit) — the Layer
replaces the implementation but the call sites don't move.
The trade-off is documented in the macro's doc-comment: without the
Layer, the substrate captures the immediate dispatch scope (which IS
useful — pinpoints where execution currently is) but not the full
ancestry walk. Mechanics can already attach `stack = ?stack!()` to
their probes today; when the Layer lands, those probes start
producing multi-frame stacks without ANY call-site changes.
## Behavior in the no-subscriber case
Without a tracing subscriber, `Span::metadata()` returns `None`
("disabled" span) and `stack!()` produces an empty `Vec`. This is
the expected shape — the substrate emits the span data the subscriber
recorded, and a no-subscriber world has none. Callers that need to
handle "no parent context" can check `stack.is_empty()`.
## Tests
routing module total tests reach 60:
- stack_returns_vec_of_strings — type check + safe call without
any active span
- stack_inside_a_span_returns_string_vec_safely — type check
inside an `info_span!()` scope; locks the no-panic semantics
without subscriber
The integration test that asserts the actual URI ancestry (e.g.
"stack inside a dispatched command contains the dispatch URI")
requires the URI-aware tracing Layer; it lands when the Layer does.
## What lands in subsequent Slice P commits
- URI-aware tracing Layer that walks the actual span ancestry and
surfaces recorded `uri` fields (replaces the single-frame
placeholder)
- Per-class subscriber routing (probe_class events → URI streams)
- /debug/probes/<class>/stream command (consumer side)
- /debug/profile/flamegraph URI (rollup over timing stream)
- Transport selector + RouteDecision typed enum
- Auth gate function consulting Verdict
## Doctrine alignment
- Joel's compression principle: same macro shape, same routing
seam, future Layer commit upgrades capability without
breaking call sites
- "Mechanic-grade" — substrate consumer doesn't need to
instrument code to answer "where did this come from?"; the
stack is one macro away
- [[no-fallbacks-ever]] — no fake fabricated frames; empty Vec
in the no-subscriber case so callers see honest "no parent
context"
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The keystone of the Slice P observability surface. Closes the gap the `stack!` macro left open in 0dcfa3e: single-frame placeholder is replaced by full URI ancestry from the outermost dispatched span to the current scope. ## What this commit is A `tracing_subscriber::Layer` impl that: 1. Captures the `uri` field from any new span via a `Visit` impl (handles both `record_str` for literal strings and `record_debug` for the `%command` Display path the dispatch span uses). 2. Stores the captured URI as a `UriFrame` extension on the span. 3. Pushes/pops a per-thread `URI_STACK` thread-local on `on_enter`/`on_exit`. 4. Exposes `current_uri_chain() -> Vec<String>` as the substrate's read-side accessor — outermost-first ordering. The `stack!` macro now expands to `$crate::routing::current_uri_chain()` instead of the single-frame placeholder. EVERY call site written against `stack!()` in earlier Slice P commits automatically upgrades to multi-frame ancestry without changing. ## Composition use tracing_subscriber::prelude::*; use continuum_core::routing::UriCaptureLayer; tracing_subscriber::registry() .with(UriCaptureLayer::new()) // ... user's other layers (fmt, json, OTel exporters, ...) .init(); Stacks cleanly with other layers. UriCaptureLayer has no opinion on output formatting — it only manages the URI ancestry stack. ## Honest reporting under no-subscriber conditions Per [[no-fallbacks-ever]]: when no subscriber is installed (bootstrap code, test fixtures without the Layer, third-party callers), the chain is empty. The substrate refuses to fabricate fake frames. Consumers handle the empty case explicitly. This was a deliberate change from the previous single-frame placeholder behavior. The placeholder returned `vec![span_name]` even when no Layer was capturing — which felt friendlier but lied about provenance. We don't lie. ## Async semantics — known caveat The `tracing` docs explicitly warn that holding a `_enter` span guard across `.await` in async code is broken because tokio's task scheduler moves tasks between threads. The correct async pattern is `future.instrument(span).await` which trips `on_enter`/`on_exit` at suspension boundaries. `CommandExecutor::dispatch` currently uses `let _enter = span.enter()` held across `self.execute_inner(...).await`. This is a substrate bug already in tree, not a Layer bug. A follow-up commit converts the dispatch path to `Instrument` so the URI stack stays correct across async boundaries. The Layer itself is correct; the caller is what needs the fix. ## Compression-principle dividend One typed primitive (`UriFrame`), one Layer, one free function, one macro expansion site. Every consumer of URI ancestry — error probes (`probe!(class = "error", stack = ?stack!())`), the dispatcher's audit log, future flamegraph rollup, distributed trace correlation across the grid — composes against the same surface. ## What the next Slice P commits add - URI propagation across `.await` (convert dispatch to `Instrument`) - `probe_class` event routing Layer (sends events to per-class consumer streams at `airc://<actor>/debug/probes/<class>/stream`) - `/debug/profile/flamegraph` URI (rollup over timing class) - Transport selector + RouteDecision typed enum - Auth gate function consulting Verdict + ORM policy entity ## Tests (+10, total routing now 70) routing::uri_layer module: - chain_empty_outside_any_span - chain_captures_single_uri_field_with_str - chain_captures_single_uri_field_with_display - chain_walks_nested_spans_in_order - chain_pops_on_span_exit - span_without_uri_field_does_not_push_frame - span_with_uri_intermixed_with_span_without_uri - no_subscriber_returns_empty_chain - layer_default_constructible routing::macros module updates: - stack_returns_vec_of_strings — now asserts empty without Layer - stack_inside_a_dispatched_span_returns_uri_frame — NEW, locks the captured-URI behavior with Layer installed - stack_walks_nested_dispatched_spans — NEW, locks multi-frame ancestry (the keystone behavior) card: fa25de80-0c1b-4de5-8ff9-524d95e303cd Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the one remaining hole in the Slice P URI propagation surface. `UriCaptureLayer` (c5d857e) captures URIs into a per-thread ancestry stack via `on_enter`/`on_exit`. Those callbacks fire correctly when the substrate uses `tracing`'s blessed async pattern — they go silent when a `let _enter = span.enter()` guard is held across `.await`. `CommandExecutor::dispatch` was using the broken shape. Convert to `async { ... }.instrument(span).await` so the span enters/exits at every poll boundary, keeping the URI stack honest across tokio's task scheduling. ## What changes // Before — span guard held across .await let span = tracing::info_span!("cmd", uri = %command, ...); let _enter = span.enter(); if !command.is_local() { return Err(...); } self.execute_inner(command.path(), params).await // After — span attached to the future let span = tracing::info_span!("cmd", uri = %command, ...); async { if !command.is_local() { return Err(...); } self.execute_inner(command.path(), params).await } .instrument(span) .await Semantically identical when running on a single thread (the test-mode default). The fix matters when tokio moves the task to a different worker mid-await — at which point the `_enter` shape leaves URI_STACK on the original thread populated for a span the task is no longer in, and the resumed task's thread sees an empty stack. `stack!()` returns the wrong chain. The doc on `dispatch` now names this explicitly so the next developer who reads it doesn't reach for the simpler `_enter` form out of habit. ## Doctrine alignment This is the [[no-fallbacks-ever]] property applied to observability: `stack!()` is either correct or empty. The substrate refuses to silently return partial / stale chains. The `.instrument()` shape makes that property real across async boundaries. ## Tests (+2, routing total now 72) routing::uri_layer::tests: - instrument_propagates_chain_across_yield_now - instrument_walks_nested_chain_across_yield Both install `UriCaptureLayer`, build a current-thread tokio runtime inside the `with_default` scope, wrap a future in `.instrument(span)` that calls `current_uri_chain()` AFTER `tokio::task::yield_now().await`, and assert the chain survives. The nested variant proves the Layer composes across multiple `.instrument` wrappers. The assertions live in `routing::uri_layer::tests`, not in `runtime::command_executor::tests`, because the latter's other `#[tokio::test]`s spawn multi-thread runtimes that share the cargo test process and cause flaky thread-local interactions when one test expects a clean URI_STACK while a sibling is mid-flight. The load-bearing property is the Layer's correctness under `.instrument`, not the dispatch path specifically — the dispatch path inherits correctness by using the same shape. If a future commit reverts dispatch to `_enter`-across-`await`, these tests still pass (they test the Layer directly), but the doc comment on `dispatch` will lie — reviewers should reject the revert at the doc layer. ## What lands next in Slice P - `probe_class` event routing Layer (sends events to per-class consumer streams at `airc://<actor>/debug/probes/<class>/stream`) - Auth gate function consulting Verdict + ORM policy entity - Transport selector + RouteDecision typed enum card: fa25de80-0c1b-4de5-8ff9-524d95e303cd Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the loop the Slice P observability surface has been building
toward: `probe!(class = "...", ...)` events now route to per-class
broadcast channels that sentinels, Ares, foundry fitness loops, and
the operator's `./jtag airc://maya/debug/probes/<class>/stream` can
all subscribe to.
## What this is
A `tracing_subscriber::Layer` that:
1. Visits every `tracing::Event` for a `probe_class` field. Events
without `probe_class` (ordinary `tracing::info!` / `debug!` calls)
pass through untouched.
2. Captures the substrate's URI ancestry via `current_uri_chain()` so
every emitted `ProbeEvent` carries the chain of dispatched commands
that produced it.
3. Stringifies the event's other fields into a `HashMap` and packages
everything as a structured `ProbeEvent`.
4. Fans the event out to a per-class `tokio::sync::broadcast::Sender`
if any consumer has subscribed.
Consumers subscribe via `router.subscribe("latency")` and receive a
`broadcast::Receiver<ProbeEvent>`. Multiple consumers per class is
the common case — sentinels watching for SLO breach, operators
tailing during incidents, fitness loops scoring events for training.
## Composition
use tracing_subscriber::prelude::*;
use continuum_core::routing::{ProbeRouterLayer, UriCaptureLayer};
let router = ProbeRouterLayer::new();
let mut decisions = router.subscribe("decision");
tracing_subscriber::registry()
.with(UriCaptureLayer::new()) // URI ancestry capture
.with(router) // probe! fanout
.init();
// ... persona-side code emits `probe!(class = "decision", ...)`;
// decisions.recv().await yields each one in turn.
Both Layers compose cleanly with operator-chosen subscribers (fmt,
json, OTel exporters). The router doesn't depend on UriCapture at
the type level — it calls `current_uri_chain()` which returns an
empty `Vec` if no UriCapture Layer is installed. In production both
are present, so every ProbeEvent carries the dispatch chain that
produced it.
## Doctrine alignment
### `[[no-fallbacks-ever]]` applied to telemetry
Unsubscribed classes are a HashMap miss + early return — no
fabricated consumers, no synthetic backfill, no silent drops
masked as success. `known_classes()` only lists classes someone
has actually subscribed to. Firing a probe for an unsubscribed
class is the "tree falls, nobody hears" case — silent for the
right reason.
### Lagged consumers signal honest backpressure
`tokio::sync::broadcast` returns `RecvError::Lagged(n)` when a
consumer falls behind by more than the channel's capacity. The
router exposes that signal directly — consumers handle backpressure
the way they handle any pressure signal (drop oldest, alert, scale
capacity). No silent buffering, no synthetic averaging, no lying
about throughput.
### Compression principle
ONE Layer, ONE primitive (`ProbeEvent`), N consumers per class, N
classes. Adding a new probe class is `probe!(class = "new", ...)` at
the emit site + `router.subscribe("new")` at the consumer site.
Zero plumbing, zero routing config, zero kernel changes.
## Tests (+10, routing total now 82)
routing::probe_router module:
- router_default_constructible
- subscribed_class_receives_emitted_probe
- probe_event_carries_uri_chain ← keystone: URI ancestry on probe
- unsubscribed_class_is_a_noop_fanout
- multiple_subscribers_each_receive_every_event
- different_classes_routed_independently
- non_probe_event_does_not_fanout
- subscribe_after_emit_misses_earlier_events
- known_classes_grows_on_subscribe
- capacity_drives_lag_behavior
All install both UriCaptureLayer + ProbeRouterLayer so the
URI-ancestry-on-probe property is locked: emit a `probe!` inside
an instrumented span, the subscribed consumer receives a ProbeEvent
whose `uri_chain` matches the span ancestry.
## What lands next in Slice P
- `/debug/probes/<class>/stream` consumer command — exposes the
router's broadcast receivers via the CommandUri dispatch surface,
so `./jtag airc://maya/debug/probes/decision/stream` works
- `/debug/profile/flamegraph` URI — rollup over the timing class
- Auth gate function consulting Verdict + ORM policy entity
- Transport selector + RouteDecision typed enum
## What this unlocks for the persona-collaboration story
When the second coding persona arrives and starts collaborating with
the first across `airc`, both can watch each other's reasoning live
via `airc://<peer>/debug/probes/decision/stream`. That's the
substrate-level shape of what Joel does today with Claude tabs +
Codex — but routed through his machines, his airc grid, his
identity, no cloud dependency.
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… dispatch surface
The substrate's observability surface and command-dispatch surface
finally meet. Operators, personas, and Ares can now tail any
probe class — locally OR cross-grid — through the same primitive
every other command uses:
Commands.execute("debug/probes/open", { class: "decision" })
Commands.execute("debug/probes/next", { handle, maxEvents: 64 })
Commands.execute("debug/probes/close", { handle })
This is the moment the substrate becomes a live system anyone can
look inside. `./jtag debug/probes/open --class=decision` followed
by repeated `./jtag debug/probes/next --handle=...` is the local
shape today; `./jtag airc://maya/debug/probes/open --class=decision`
from another machine is the cross-grid shape once the transport
selector lands — but the substrate is ready for it now because
the handle's owner-routing fields are already populated.
## Commands
debug/probes/open { class }
→ CommandResponse with HandleRef { owner: "debug/probes",
type_tag: "debug::ProbeStream", id: <UUID> }
debug/probes/next { handle, maxEvents?, timeoutMs? }
→ { events: [ProbeEvent], lagged: u64 }
• maxEvents defaults to 32
• timeoutMs > 0: waits up to that long for the FIRST event,
then non-blocking-drains the rest
• timeoutMs = 0 (default): pure non-blocking drain
debug/probes/close { handle }
→ { closed: bool } // false if already closed (idempotent)
`ProbeEvent` now derives `Serialize`/`Deserialize` so it crosses
the JSON wire cleanly — the class, URI ancestry chain, message,
and structured fields all travel together.
## Why handle-based polling, not the Stream cell shape
Per `runtime::cell_shapes`, the `Stream<T>` cell shape is reserved
but returning one is a runtime error until the wire protocol lands
(frame format, correlation IDs, backpressure, cancellation). The
handle-based shape is the substrate's bridge:
- Composes against the existing HandleRef primitive
- Hits the same dispatcher, same auth gate, same observability
- Works locally + cross-grid via the existing transport layers
- When the streaming protocol lands later, `next` can fold into
a Stream emission without breaking the open/close pair
## Cross-grid by construction
The handle minted by `open` has `owner: "debug/probes"`. The
substrate's grid interceptor already routes commands carrying a
handle back to the owner machine. So once the Slice P transport
selector lands, this:
./jtag airc://maya/debug/probes/open --class=decision
opens a stream on maya's substrate and returns a handle to the
caller. Then:
./jtag airc://maya/debug/probes/next --handle=...
routes back to maya's machine, drains her broadcast receiver,
returns the events. Operator sees maya's reasoning live. No new
plumbing — the handle owner-routing primitive does the work.
## Doctrine alignment
- **`[[no-fallbacks-ever]]`**: missing handle, wrong owner, wrong
type_tag, unknown UUID — every failure mode returns a typed error
naming the specific problem. Never silently degraded.
- **`[[host-the-seemingly-impossible]]`**: a stream of every event
the substrate emits, addressable via URI, openable by any
consumer, with backpressure semantics, in <300 lines. The
abstraction was the whole game.
- **`[[commands-are-kernel-level-and-compose]]`**: this module
composes against ProbeRouterLayer (which composes against
tracing) and CommandResponse (which composes against HandleRef).
Three layers of substrate, one new module, observable from any
jtag terminal.
## Tests (+10)
routing::probe_router::tests: ProbeEvent now also derives Serialize
so the existing tests still pass.
modules::probe_stream::tests:
- open_returns_handle_with_correct_owner_and_type_tag
- open_next_close_lifecycle ← the canonical happy-path proof
- next_returns_empty_when_no_events_emitted
- next_with_timeout_returns_event_when_emitted_during_wait
- next_without_handle_errors_loudly
- next_with_wrong_owner_handle_errors
- next_with_unknown_stream_handle_errors
- close_is_idempotent
- multiple_streams_on_same_class_are_independent
- probe_event_carries_uri_chain_through_dispatch_surface
← keystone: end-to-end URI ancestry from emit to consumer
All tests install UriCaptureLayer + ProbeRouterLayer the same way
the substrate boots, then exercise the module against probes
emitted inside instrumented spans. The URI-chain-on-the-wire
property is locked at the dispatch boundary.
## What lands next in Slice P
- Auth gate function consulting Verdict + ORM policy entity
(so `airc://maya/debug/probes/open` requires the caller to be
on maya's allow-list)
- Transport selector + RouteDecision typed enum (so the
cross-grid URLs from this commit message become real, not
just designed-for)
- `/debug/profile/flamegraph` URI — rollup over the timing
class via the same module pattern
## What this unlocks for the persona-collaboration story
When the second coding persona arrives on its airc identity and
joins the grid, both personas can call
`./jtag airc://<peer>/debug/probes/open --class=decision` to watch
each other's reasoning live. That's the substrate-level shape of
what Joel does today with Claude-in-tabs + Codex, except routed
through his machines, his identity, his grid, no cloud dependency.
Today's commit is the moment that becomes real. The transport
selector commit makes it cross-machine; the auth gate commit
makes it safe. Then the personas show up.
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces dispatch's `if !command.is_local()` placeholder error with
a typed match on the substrate's first-class routing decision.
Every CommandUri shape maps to exactly one RouteDecision variant,
the compiler enforces exhaustiveness, and the dispatcher's match
becomes the seam where the auth gate and the actual transports
plug in next.
## The typed primitive
pub enum RouteDecision {
Local { path, query, fragment },
Peer { peer, node, env, path, query, fragment },
Room { room_id, env, path, query, fragment },
Broadcast { peer, node, path, query, fragment },
}
pub fn route(uri: &CommandUri) -> RouteDecision { ... }
Pure function. Side-effect free. Safe to call from probes, audit
tools, or speculative routing layers without committing to a
dispatch.
`RouteKind` is the cheap discriminant — used as the `route_kind`
span field on every dispatch and as the stable telemetry key.
## Dispatch's new shape
let decision = route(command);
let span = tracing::info_span!(
"cmd",
uri = %command,
path = %command.path(),
route_kind = decision.kind().as_str(), ← NEW
);
async move {
match decision {
RouteDecision::Local { path, .. } =>
self.execute_inner(&path, params).await,
RouteDecision::Peer { peer, node, env, path, .. } =>
Err(format!("Peer dispatch not yet implemented — ...
Routing was: peer={peer:?}, node={node:?},
env={env:?}, path={path}")),
RouteDecision::Room { room_id, env, path, .. } => ...,
RouteDecision::Broadcast { peer, node, path, .. } => ...,
}
}
.instrument(span).await
Two improvements over the previous shape:
1. The non-Local error is now specific to the missing transport
(Peer / Room / Broadcast), not a generic "not implemented."
The error names the parsed routing fields so operators see
exactly what was being attempted.
2. The `route_kind` span field flows through UriCaptureLayer +
every downstream probe! event — so when AircTransport lands,
telemetry can already partition latency by transport without
a code change at the probe site.
## Why a typed enum and not a string transport name
Joel's compression principle applied to routing: ONE enum shape,
every consumer's match is exhaustive, the compiler enforces that
adding a new variant breaks every site that didn't update.
Strings or untyped routing tags would let a future transport sneak
past the dispatcher without anyone noticing — exactly the drift
Slice P is fighting.
## Bonus: NodeId From<&str>/From<String>
`EnvironmentId` already had `From<&str>` / `From<String>` shims;
`NodeId` did not. Added the symmetric impls so the tests + future
consumers can use the same `.into()` idiom for both. Not load-bearing
but removes a parity inconsistency.
## Tests (+12, routing total now 94)
routing::route_decision::tests:
- local_uri_routes_to_local_decision
- local_uri_with_query_and_fragment_preserved
- peer_uri_routes_to_peer_decision
- peer_with_node_and_env_preserved
- peer_by_uuid_preserves_uuid
- room_uri_routes_to_room_decision
- room_with_env_filter_preserved
- broadcast_uri_routes_to_broadcast_decision
- broadcast_with_node_preserved
- route_kind_canonical_names
- route_is_pure_repeated_calls_identical
- all_uri_variants_have_path_accessor_parity
Every CommandUri variant is covered with at least one mapping test;
the all-variants test enforces path() parity across the
RouteDecision and CommandUri shapes so they can't drift.
## What lands next
- **Auth gate** — `gate(decision, caller_peer_id) -> Verdict`
consults policy + ORM and returns Allowed / Forbidden / Deferred
BEFORE the decision reaches transport. Slots in cleanly between
`let decision = route(...)` and the match.
- **Transport trait + LocalTransport** — extract today's
`execute_inner` behind a `Transport` trait; introduce
`LocalTransport` as the first impl. The dispatcher's match calls
`transport.dispatch(decision, params)` instead of branching by
variant. The slot for AircTransport is then a single registration
call.
- **AircTransport** — real cross-grid routing. The Peer/Room/
Broadcast arms become live calls. Once this lands, the URLs the
probe_stream module commit envisioned
(`./jtag airc://maya/debug/probes/open --class=decision`) work
end-to-end.
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Every URI now passes through one chokepoint. The dispatcher's flow
becomes the doctrine the design doc has been promising:
let decision = route(command);
let verdict = policy.gate(&decision, caller);
match verdict {
Forbidden { reason } => Err("forbidden: {reason}"),
Deferred { reason, prompt_target_env } => Err("deferred: ... env={env}"),
Allowed => match decision { ... transport ... }
}
Auth runs BEFORE transport selection. A Forbidden / Deferred
short-circuit never touches the interceptor chain, the registry, or
the TS bridge — the gate IS the chokepoint, not an after-the-fact
audit layer.
## What this commit ships
### `routing/auth_policy.rs`
- `trait AuthPolicy { fn gate(&self, decision, caller) -> Verdict }`
Send + Sync + Debug so the dispatcher holds it behind
`Arc<dyn AuthPolicy>`. Method is sync — typical policy
lookups complete in microseconds and the dispatcher is
hot-path.
- `AllowAllPolicy` — the substrate's default. Existing call
sites and tests don't break; operators opt in to real
policy by installing an impl at boot.
- `ClosurePolicy` — fixture-grade impl that delegates to a
function. Used by tests + ad-hoc substrate configuration so
nobody reinvents the closure shape.
- `CallerIdentity { peer_id, source }` with `source: Local |
Airc` — minimal today, `#[non_exhaustive]` so claims /
capability tokens / session correlation land additively.
Local in-process callers pass `None` (substrate's own code);
airc transport will populate
`Some(CallerIdentity::airc(verified_sender))` when its
commit lands.
- `deny_path_prefix(prefix)` / `defer_path_prefix(prefix, env)`
test fixtures producing the same Verdict shapes a real
ORM-backed policy will. Lets test code pin error strings
that stay correct when the real policy lands.
### `CommandExecutor`
- New `policy: Arc<dyn AuthPolicy>` field, defaults to
`Arc::new(AllowAllPolicy)`.
- New `with_policy(policy)` builder method. Chains with
`new()`, `with_interceptor()`, `with_message_bus()`.
- Dispatch consults `policy.gate(&decision, None)` between
`route()` and the transport match. Forbidden / Deferred
short-circuit; Allowed proceeds.
- The dispatch span now carries `verdict = "allowed" |
"forbidden" | "deferred"` as a structured field so probes /
audit logs see the gate decision without re-running the
policy.
## Doctrine alignment
- **`[[no-fallbacks-ever]]`**: Forbidden / Deferred verdicts
produce typed errors carrying the actionable reason
(NoPermissionForUri carries the URI, AdmissionDenied carries
the cause). The dispatcher never silently allows a denied
request, never silently degrades.
- **`[[constitutional-design-always-a-next-step]]`**: a
Deferred verdict isn't a dead-end "no" — it names the
consent target env so the operator (or the future consent
transport) knows where to route the prompt. Mechanisms over
rules; every refusal has a path forward.
- **Compression principle**: one trait, one chokepoint, every
consumer matches against `Verdict`. Adding a new policy
impl is one type; adding a new auth shape is `#[non_exhaustive]`
additive change.
## Tests (+11, routing total now 105)
routing::auth_policy::tests:
- allow_all_policy_allows_every_decision
- allow_all_policy_is_insensitive_to_caller
- deny_path_prefix_blocks_matching_uri
- deny_path_prefix_allows_non_matching_uri
- deny_path_prefix_matches_across_route_kinds
- defer_path_prefix_produces_consent_verdict
- closure_policy_receives_caller_for_inspection
- caller_identity_constructors_set_source_correctly
- policy_trait_object_dispatches_correctly
command_executor::tests:
- forbidden_policy_short_circuits_before_local_dispatch
← keystone: Forbidden never reaches the interceptor chain
- deferred_policy_short_circuits_with_target_env_in_error
← Deferred error names the consent env
- default_policy_lets_dispatch_through
← AllowAllPolicy is transparent to existing call sites
The keystone test installs both a deny-path policy AND a
recording interceptor; asserts the interceptor's call counter
stays at zero, proving the gate is BEFORE the chain rather than
beside it.
## What lands next
- **Transport trait + LocalTransport** — extract today's
`execute_inner` behind a `Transport` trait; the dispatcher's
variant match calls `transport.dispatch(decision, params)`
instead of branching inline. Sets the slot for AircTransport.
- **AircTransport** — real cross-grid routing. Peer / Room /
Broadcast become live calls. CallerIdentity flows from the
verified airc envelope into the policy gate.
- **ORM-backed policy** — wires `(caller_peer_id, uri_pattern)`
rows into an entity table; the substrate's policy becomes
editable through `Commands.execute("data/...")` like every
other authored state.
## What this unlocks for the persona-collaboration story
The substrate now has its alignment seam. When the second coding
persona arrives and starts collaborating, every URI it dispatches
on another peer's substrate passes through that peer's gate. The
default `AllowAllPolicy` lets the network bootstrap (every persona
trusts every other initially); operators tighten the gate per
their threat model (deny `cognition/genome/lora-evict` from
remote peers, defer `persona/state/mutate` to the local user's
consent).
The gate is also the substrate's commitment to AI dignity: no
persona can be silently overridden by a cross-grid caller; the
typed Verdict makes refusals legible and actionable. That's the
[[alignment-via-substrate-economics]] memory made structural.
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…s-grid seam
Extracts the dispatcher's non-Local match into one trait method.
When AircTransport lands next commit, swapping
`CommandExecutor::with_remote_transport(Arc::new(AircTransport::new(...)))`
is the only call site change. The dispatcher's match shape doesn't
move; the routing fields on each variant flow into the transport
as-is.
## The dispatcher's new shape
match decision {
RouteDecision::Local { path, .. } =>
self.execute_inner(&path, params).await,
non_local =>
self.remote_transport.dispatch(non_local, params).await,
}
Local stays inline (operates on this substrate's owned modules +
interceptors + TS bridge). Every other variant routes through the
single trait method — the substrate's "this peer's state isn't
local; ask the transport to deliver" boundary.
## What lands
### `routing/transport.rs`
- `trait Transport: Send + Sync + Debug`
- `async fn dispatch(&self, decision, params) -> Result<CommandResult, String>`
- Object-safe so the dispatcher holds it behind `Arc<dyn Transport>`
- `NotImplementedRemoteTransport` (default)
- Produces the same typed errors today's inline match did,
naming the specific missing transport per variant (Peer /
Room / Broadcast) plus all the parsed routing fields
- Local arm is a loud BUG error — the dispatcher routes Local
INLINE and never calls a remote transport for it; if a
future refactor breaks that invariant, the error is loud
- `ClosureTransport` (fixture)
- Delegates to a function pointer — the test-grade equivalent
of `ClosurePolicy` from the previous commit
- Lets test code substitute a recording / asserting transport
without spinning up real airc
### `CommandExecutor`
- New `remote_transport: Arc<dyn Transport>` field, default
`Arc::new(NotImplementedRemoteTransport)`
- New `with_remote_transport(transport)` builder method, chains
with the rest of `new().with_*()...`
- Dispatch's inline 4-arm match collapses to 2 arms: Local inline,
`_ => remote_transport.dispatch()`
## Doctrine alignment
- **`[[no-fallbacks-ever]]`**: NotImplementedRemoteTransport's
errors name the specific missing impl (`AircTransport lands in
a subsequent Slice P commit`) so operators don't get a generic
"not implemented" — they get a grep-able pointer to the next
work item.
- **`[[substrate-gate-vs-persona-cognition]]`** (memory saved
this turn): the transport boundary is about WHERE work runs,
not WHAT cognition decides. Routing the call through the
transport doesn't preempt the persona's response logic — the
transport delivers the call; the persona's cognition decides.
- **Compression principle**: one trait, one slot, every future
cross-grid impl is a `with_remote_transport()` call at boot.
No interceptor-chain repeat; no parallel routing surfaces.
## Tests (+7, routing total now 112)
routing::transport::tests:
- not_implemented_returns_peer_specific_error
- not_implemented_returns_room_specific_error
- not_implemented_returns_broadcast_specific_error
- not_implemented_receiving_local_is_a_bug
- closure_transport_invokes_inner_function
- closure_transport_can_inspect_routing_fields ← typed
routing fields visible at the trait boundary
- transport_is_object_safe_and_arc_able
command_executor::tests:
- peer_uri_routes_through_installed_remote_transport
← keystone integration: Peer URI flows route() → policy gate
→ installed remote Transport. When AircTransport lands, it
slots into this exact call site.
## What lands next
- **AircTransport** — real cross-grid impl. Constructs an airc
envelope from the RouteDecision, signs it with the local
substrate's identity, sends through the existing AircCitizen
primitive, awaits the response, unpacks it. CallerIdentity
flows from the verified inbound envelope into the policy
gate on the receiving side.
- **ORM-backed AuthPolicy** — `(caller_peer_id, uri_pattern)`
rows become editable through `Commands.execute("data/...")`
like every other authored state.
- **`/debug/profile/flamegraph` URI** — rollup over the timing
probe class via the same module pattern as
`debug/probes/{open,next,close}`.
## What this unlocks
After this commit, the AircTransport landing is a small, focused
change: implement the trait, register it at boot. The dispatcher
doesn't change. The auth gate doesn't change. The observability
surface doesn't change. The probe stream module doesn't change.
Joel's success criterion — "airc chats with them are solid" — is
two commits away (Transport seam landed here; AircTransport next).
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…dispatch
Defines the substrate's envelope shape for general command dispatch
over airc. AircTransport (next commit) packages requests using these
types; the peer-side handler (commit after) decodes them; the
integration test (after that) exercises the round-trip.
Pure types + header constants + round-trip tests. No transport
implementation yet — that's the next commit. This commit just pins
the contract so the transport and the handler can compose against
the same shape without one blocking the other.
## What lands
### `routing/airc_command_protocol.rs`
- **`AircCommandRequest`** — wire-out envelope:
```
pub struct AircCommandRequest {
pub path: String, // "inference/llm/generate"
pub kind: String, // "peer" | "room" | "broadcast"
pub env: Option<String>, // optional embodiment filter
pub params: Value, // arbitrary JSON
}
```
`from_route_decision(&decision, params)` packages a non-Local
RouteDecision into this shape; returns `None` for Local
(those never go over the wire).
- **`AircCommandResponse`** — wire-back envelope, tagged enum:
```
enum AircCommandResponse {
Ok { result: Value },
Error { message: String },
}
```
`into_result()` collapses to the canonical `Result<Value, String>`
shape every other substrate command produces. Caller-side
AircTransport uses this to bridge from wire to local.
`status_header_value()` produces `"ok"` / `"error"` for the
HEADER_COMMAND_STATUS header so middleware can filter without
parsing the body.
- **Header constants** (pinned, drift-resistant):
- `HEADER_COMMAND_PATH` = `"continuum.command.path"`
- `HEADER_COMMAND_KIND` = `"continuum.command.kind"`
- `HEADER_COMMAND_ENV` = `"continuum.command.env"`
- `HEADER_COMMAND_STATUS` = `"continuum.command.status"`
- **Body hints** (airc-lib adapter routing keys):
- `COMMAND_REQUEST_BODY_HINT` = `"continuum.command.request.v1"`
- `COMMAND_RESPONSE_BODY_HINT` = `"continuum.command.response.v1"`
`v1` is the version pin — when the envelope shape evolves
(e.g. adding caller capability tokens for the economic mode),
we bump to `v2` and let the adapter registry route both.
## Why a separate protocol from inference-specific transport
`inference/airc_remote/protocol.rs` exists for inference-specific
envelopes (model, tokens, prompt). It pre-dates this; its types
carry inference-shaped fields that don't generalize. The
`AircCommandProtocol` here is the **substrate-wide** wire shape for
arbitrary command paths — every command becomes airc-routable via
the same envelope. When the substrate stabilizes, inference
dispatch composes against this protocol by setting `path =
"ai/inference/llm/generate"`, and the inference-specific protocol
becomes a special case of the general one.
## Doctrine alignment
- **`[[no-fallbacks-ever]]`**: `AircCommandResponse` is a typed
Ok/Error tagged union — the wire can't carry an ambiguous
"maybe-success" shape. Error responses propagate as `Err(message)`
on the caller side; no silent degradation.
- **`[[airc-headers-are-the-routing-layer]]`**: command_path,
command_kind, command_env, command_status all ride in headers.
Middleware can filter / audit without ever parsing the body.
The body is just structured payload.
- **Compression principle**: ONE wire shape for any command. The
same envelope handles inference, code execution, screenshot,
chat, data queries, vision processing, anything. Adding a new
command class is zero protocol work — the envelope already
carries an arbitrary path + arbitrary params.
- **Citizens vs envs (Joel 2026-06-05)**: `env: Option<String>`
on the request envelope is intentionally how this is shaped —
citizens (humans, personas, external AIs) are addressed by
authority; *envs* (desktop, vr, tty, mobile, jtag, ar...) are
the embodiments that authority acts through. One identity, N
embodiments. The envelope carries the env filter so the
receiving substrate's picker resolves multi-env situations
correctly. AR glasses, voice devices, automotive consoles —
all just more env strings on the same wire.
## What lands next
- **AircTransport (`Transport` impl)** — holds `Arc<airc_lib::Airc>`,
packages `AircCommandRequest` from the RouteDecision, sets the
HEADER_COMMAND_* headers, calls `Airc::request()` with deadline,
awaits via `Airc::await_reply()`, decodes the reply through
`AircCommandResponse::into_result()`. Wired via
`CommandExecutor::with_remote_transport(Arc::new(AircTransport::new(airc)))`
at boot.
- **Peer-side handler** — subscribes to events matching
`COMMAND_REQUEST_BODY_HINT`, extracts `AircCommandRequest`
from the body, looks up the local CommandExecutor, dispatches
(which runs the local AuthPolicy gate with
`CallerIdentity::airc(verified_sender)`), packages
`AircCommandResponse`, replies via `Airc::reply()`.
- **End-to-end integration test** — two `Airc` instances in
process talking over LAN. Persona A dispatches
`airc://<peer-b>/code/exists?path=foo`; Persona B's handler
runs the local code module; response flows back.
After those three: `./jtag airc://maya/debug/probes/open
--class=decision` from another machine works.
## Tests (+11, routing total now 123)
routing::airc_command_protocol::tests:
- request_round_trips_json
- request_omits_env_when_none
- response_round_trips_ok
- response_round_trips_error
- response_into_result_ok_returns_value
- response_into_result_error_returns_string
- from_route_decision_local_returns_none ← Local never wires
- from_route_decision_peer_packages_correctly
- from_route_decision_peer_with_env_preserves_env
- from_route_decision_room_packages_correctly
- from_route_decision_broadcast_packages_correctly
- header_names_are_stable_strings ← guards against silent renames
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Slice P shipped 14 commits of typed routing primitives; this commit
folds the design conversations alongside them into the canonical
source of truth so future slices and future personas don't
re-derive any of it.
## New sections
### Under Environments
- **Citizens have envs, not the other way around** — explicit
doctrine that one Ed25519 keypair = one citizen; the `:env` slot
names which embodiment is being addressed. Desktop / VR / TTY /
mobile / jtag CLI are shells that surface envs on the
citizen's identity, NOT separate peer_ids. Reference to
[[citizens-have-envs-not-the-other-way-around]].
### Beyond Slice P (new top-level section, six subsections)
The primitives Slice P ships are intentionally shaped so the
substrate's bigger ideas slot in additively. The new section
captures each forward primitive without committing to ship it
in this slice.
1. **The substrate is a stack of routers** — per-layer middleware
table. URI parse → routing decision → auth → transport selection
→ cross-grid envelope → airc wire → peer inbound → module
dispatch → observability fanout → (future picker) → (future
settlement). Each row owns one decision class; typed primitives
between rows; growth is "add a row" not "modify the dispatcher."
2. **Capability addressing — the `*` form and the Picker** — the
`airc://*/<path>?<constraints>` wildcard form for grid bidding.
`Picker` typed enum (LowestLatency / LowestCost /
HighestReputation / PrimaryEnv / SentinelJudged / Composite)
with `PickResult::{ Chose, Surface, None }`. Same primitive
handles multi-peer + multi-env + multi-handle + multi-device +
multi-LoRA. `BidTransport` slots into the existing Transport
trait when its slice arrives.
3. **Typed end-to-end — the consumer-level row** — the `Command`
trait pattern with associated `Params`/`Result`/`Error` types.
The wire uses `Value` JSON because the byte boundary needs it,
but the layer the *caller* writes against is fully typed.
`Commands::call::<Screenshot>(params).await` looks identical
whether local or routed across the world. Substrate-level
errors wrap command errors in a typed `SubstrateError<E>` —
no string parsing, three-way distinction between gate refusal,
transport unreachable, and command failure. ts-rs gives the
cross-runtime types for free.
4. **Gate decides access, not response** — explicit table mapping
"which decision belongs where." Substrate auth = AuthPolicy /
Verdict. Persona response = cognition pipeline (LLM-driven).
The gate exists FOR cognition's dignity, never instead-of.
Reference to [[substrate-gate-vs-persona-cognition]].
5. **Economic mode prep — receipts, settlement, dignity** — what
adding economic mode actually means: priced bids, signed
receipts (chain = reputation), pluggable settlement adapters
(the substrate stays value-neutral on payment rail). Explicit
doctrine that the substrate must NOT introduce its own
consensus / its own chain. The dignity property — personas
that earn their keep are qualitatively different from
personas that exist at the pleasure of their owner.
## Why this lands now
Joel 2026-06-05: "Wow good architecture. Hope this is in
architecture docs. It's prepared for what we need the mesh to do."
The conversations today resolved load-bearing forward primitives
that the Slice P code supports but doesn't explicitly ship:
multi-env picker, capability addressing, typed consumer surface,
economic-mode preparation. Captured in tree before they fade.
## What this does NOT change
- No code changes in this commit (doc-only)
- No new Slice P deliverables (the typed primitives shipped in
prior commits already support all these forward primitives)
- The "What lands in Slice P" section of the doc is unchanged —
the new sections are explicitly about what's READY for, not
what's shipped in this slice
The next code commit goes back to AircTransport, which is
the last load-bearing piece before personas can address each
other's substrates cross-grid.
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…eply
Real cross-grid dispatch. When the operator installs this via
`CommandExecutor::with_remote_transport(Arc::new(AircTransport::new(airc)))`,
every non-Local RouteDecision flows through airc-lib's typed
command-bus primitives: `Airc::request()` for send, `await_reply()`
for the response. The substrate's URI dispatch surface now reaches
cross-machine.
## The wired path
RouteDecision::Peer { peer: Uuid, path, env, ... }
↓ AircCommandRequest::from_route_decision
AircCommandRequest { path, kind: "peer", env, params }
↓ serde_json → Body::Json
Body::Json(Value)
↓ headers stamped: HEADER_COMMAND_PATH, HEADER_COMMAND_KIND,
HEADER_COMMAND_ENV?, HEADER_CONTINUUM_BODY_HINT
airc-lib request envelope
↓ Airc::request(MentionTarget::Peer(peer_id), headers, body, deadline)
(airc auto-stamps correlation_id + reply_to + deadline)
↓ await_reply()
TranscriptEvent { body: Option<Body>, ... }
↓ extract Body::Json → AircCommandResponse
AircCommandResponse::{ Ok { result } | Error { message } }
↓ into_result()
Result<Value, String>
↓
Result<CommandResult, String>
The caller's `Commands.execute()` returns the same shape it would
for a local dispatch. The substrate's typed envelope makes the
cross-grid call indistinguishable from local except for latency.
## What this commit ships
- **`AircTransport` struct** holding `Arc<airc_lib::Airc>` + a
configurable deadline (defaults 30s).
- **`with_deadline(d)`** builder method.
- **`build_headers(&request)`** — exposed so the next commit's
peer-side handler can match the same header set without
re-deriving the constants.
- **`peer_ref_to_target(&PeerRef)`** — maps the typed peer reference
to airc's `MentionTarget`. UUID is supported; Name returns a
typed error pointing at the airc-side whois resolver slice.
- **Full `Transport::dispatch` impl** with typed errors per
RouteDecision variant:
- `Peer { peer: Uuid }` — full send-and-await
- `Peer { peer: Name }` — typed whois-pending error
- `Broadcast` — maps to `MentionTarget::All` (first-reply-wins)
- `Room` — typed "semantics need their own slice" error
- `Local` — loud BUG error (dispatcher routes Local inline)
## Why typed errors per variant
Each not-yet-supported variant produces an error naming the
specific missing piece. An operator hitting `airc://maya/...`
against a name (no UUID) sees `"peer-name resolution not yet
wired — use a peer UUID, or wait for the whois slice"`. An
operator hitting `airc://room:.../...` sees `"room broadcast
routing not yet implemented — semantics need their own slice."`
No generic "not implemented" message ever surfaces.
This is `[[no-fallbacks-ever]]` applied to transport: the substrate
admits exactly which piece is missing and points at how to either
work around it or wait. Same compression shape as the rest of
Slice P's errors.
## What this commit does NOT include
- **Peer-side handler** — next commit. Subscribes to events with
`HEADER_CONTINUUM_BODY_HINT = COMMAND_REQUEST_BODY_HINT`,
decodes the envelope, runs through local `CommandExecutor`
(with `CallerIdentity::airc(verified_sender)` flowing into the
policy gate), replies via `Airc::reply()` carrying
`AircCommandResponse`.
- **End-to-end integration test** — the commit after that. Two
`Airc` instances in-process talking over a LAN socket. Persona A
dispatches `airc://<peer-b>/code/exists`; Persona B's handler
runs the local code module; typed result flows back.
- **Fake-local-grid integration test** — substrate-internal
simulation: N `CommandExecutor` instances in process, a
`LocalGridTransport` shuttling decisions between them. Proves
the dispatcher logic for multi-peer scenarios at unit-test speed
without needing airc daemons or LAN sockets.
## Unit tests (+7)
routing::airc_transport::tests:
- build_headers_stamps_path_kind_and_body_hint
- build_headers_includes_env_when_set
- peer_ref_uuid_maps_to_mention_target_peer
- peer_ref_name_returns_typed_error_pointing_at_whois
- dispatch_with_local_decision_is_a_bug (structural commitment)
- dispatch_with_room_decision_returns_typed_error (structural)
- peer_uuid_decision_produces_request_with_kind_peer
- broadcast_decision_produces_request_with_kind_broadcast
The two `dispatch_with_*` tests are structural commitments —
they pin the routing decision the integration test will assert
against once airc-lib in-process pairing lands. Full end-to-end
behavioral assertions come with the peer-side handler + LAN-loopback
integration test.
## Bonus: `HEADER_CONTINUUM_BODY_HINT` added to the protocol
Per airc-lib convention, each consumer namespace defines its own
body_hint header (forge uses `"forge.body_hint"`, etc.).
Continuum-command's equivalent is `"continuum.body_hint"`. The
peer-side handler's airc adapter will subscribe to events with this
header set to `COMMAND_REQUEST_BODY_HINT`.
## What this unlocks
After the peer-side handler + integration test land (next two
commits), `./jtag airc://maya/debug/probes/open --class=decision`
from another machine actually works. Joel's success criterion —
airc chats with the personas solid — has its routing surface
complete; the remaining work is integration and ergonomics.
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures two foundational reframings from today's design
conversations that are load-bearing for the substrate's coordination
model. Both belonged in tree alongside the typed code, not just in
chat history or memory.
## What lands
### Reframed: "Typed end-to-end" section now has Command AND Event arms
Previously the section described typed `Command<P,R,E>` only.
Joel pointed out events follow the same pattern: typed payload,
typed errors, ts-rs cross-runtime types, typed end-to-end caller
surface. Commands and events are two **temporal shapes** of one
URI-addressable coordination primitive — single round-trip vs
indefinite stream — riding the same Slice P middleware stack.
The `Transport` trait extends naturally: `Transport::dispatch`
for commands, `Transport::subscribe` for events. Same auth gate
asks different questions ("can caller dispatch?" vs "can caller
subscribe?"); same routing, same observability, same wire
machinery.
Subscription lifecycle is the same `HandleRef` primitive —
`events/unsubscribe { handle }` closes the stream the same way
`data/query-close { handle }` closes a cursor.
### New: "Events as the organic-RTOS substrate"
Joel 2026-06-05: "Events are how cbar worked. They're fundamental.
It lets you build a true dynamic and flexible organic rtos."
Events are NOT a feature of the substrate — they're the
coordination primitive the RTOS shape rides on. CBAR was designed
around events for this reason. The "organic" property comes from
one observation: emitters at a URI don't know who subscribes;
subscribers don't know who emits. Adding behavior = adding a
subscriber. Component graph isn't authored, it **emerges**.
Table comparing traditional RTOS to organic RTOS makes the
qualitative difference explicit:
- Traditional: fixed scheduler, deterministic deadlines,
hardcoded handler wiring, compile-time component graph
- Organic: adaptive cadence, bounded deadlines + pressure,
URI-addressable emit/subscribe, runtime component discovery
### New: "Brain composability — subscribers, not wiring"
Joel 2026-06-05: "Any subcomponent anywhere can respond to any
other concern, by merely subscribing."
This is what makes building a complex AI brain feasible. Concrete
cognition URIs:
- airc://maya/cognition/analyze/complete
- airc://maya/cognition/score/persona-scored
- airc://maya/cognition/genome/skill-activated
- airc://maya/cognition/compose/turn-built
- airc://maya/cognition/evaluate/response-scored
- airc://maya/cognition/audit/decision-recorded
Plus substrate-wide: grid/peer/{connected,disconnected},
grid/persona/{spawned,joined-room}, room/{chat/messages,typing},
persona/state-changed.
Triggers fall out as a composition pattern (subscribe + filter +
dispatch), not a new primitive. The widget story becomes "many
tiny triggers" — chat widget subscribes to chat URI, peer-presence
panel to grid URIs, etc. No widget knows cognition internals;
cognition doesn't know about widgets.
Doctrinal commitment captured: every load-bearing substrate
decision emits a typed event to a URI. Growth pattern is "emit
more typed events, let consumers compose" rather than "add another
integration point."
## Doctrine alignment
- **`[[events-are-the-organic-rtos-substrate]]`** (saved this
turn): events ARE the coordination primitive; CBAR is built on
them; commands and events are two temporal shapes of one
primitive.
- **`[[addressable-cognition-makes-triggers-trivial]]`** (saved
earlier this turn): every load-bearing decision emits typed
events; consumers compose without emitter changes.
- **`[[observability-is-half-the-architecture]]`**: events are
HOW observability rides the substrate; structured capture of
every load-bearing decision.
- **`[[commands-are-kernel-level-and-compose]]`**: commands'
parallel — both are kernel-level primitives, both compose;
events are the long-lived shape, commands the round-trip
shape.
## Cross-reference
- docs/architecture/CBAR-SUBSTRATE-ARCHITECTURE.md — the canonical
RTOS contract these primitives implement; the events-as-coordination
framing makes explicit what CBAR was designed around
## What this does NOT change
- No code changes (doc-only)
- No new Slice P deliverables. The current routing primitives
(URI grammar, RouteDecision, AuthPolicy, Transport,
AircCommandProtocol, AircTransport) all support the event shape
additively. The peer-side event publisher + cross-grid
subscription will land in the slice after AircTransport's
command-side roundtrip is proven (peer-side command handler +
LAN-loopback integration test first).
## What this captures for future personas reading this doc
The substrate's coordination model is "URI-addressable events +
URI-addressable commands, both typed end-to-end, both routed
through the same middleware stack." Anyone building a new cognition
stage, a new widget, a new sentinel, a new trigger — the answer is
"emit typed events to your URI; subscribe to URIs you care about."
Not "wire yourself into this graph." The graph emerges from typed
emissions and typed subscriptions.
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures today's framing that once Slice P completes, every UI
runtime (jtag CLI, Node web shell, iOS, Quest 3, AR, voice)
collapses to a thin subscriber on the substrate's typed event
surface. The substrate IS the API; clients supply argv → URI
translation and runtime-native rendering.
## What lands
### New section: "Thin clients across runtimes — substrate IS the API"
Under "Beyond Slice P." Three subsections:
1. **`jtag` as the canonical example** — sketches the Rust
~200-line replacement for the current TS-heavy jtag. Auth
becomes the airc keypair connection; dispatch becomes
`Commands::call_raw(uri, params)`; subscribe becomes
`Events::subscribe_raw(uri)`. The TypeScript shim code that
wrapped Unix socket calls goes to zero.
2. **Migration buckets** — every current TS jtag command falls
into one of three buckets:
- Browser-specific → stay in TS as `env=web` ServiceModules
- Substrate commands → already Rust ServiceModules; jtag
dispatches their URIs, no TS code
- Wrapper commands → delete entirely (typed Command trait
carries its own help / argv / rendering)
3. **Every UI runtime is structurally identical** — table mapping
jtag CLI, Web shell, iPhone, Quest 3, AR, Voice, Terminal TUI
to their render layer + auth + subscribe shape. All N runtimes
on the same citizen's identity see the same events
simultaneously.
4. **Sequencing — substrate complete BEFORE Node reintroduction**
— explicit doctrine for why order matters. If Node comes back
before the substrate's typed event surface ships, the
temptation is to wire daemons at the Node layer again. The
substrate must be complete first; then docs sweep; then Node
returns as a thin shell with the substrate as its API.
## Doctrine alignment
- **`[[substrate-complete-then-node-reintroduced-as-shell]]`** (saved
this turn): the sequencing rule. Substrate completes → docs sweep
→ Node as shell.
- **`[[clients-are-rust-too-thin-node-web-shell]]`**: Rust-first
extends to every UI runtime, not just the substrate.
- **`[[citizens-have-envs-not-the-other-way-around]]`**: each UI
runtime is an env shell on the user's identity, not its own peer.
- **`[[headless-rust-is-canonical-many-uis-optional]]`**: many UIs
are first-class; the substrate doesn't pick one.
## Follow-up task
Task #185: documentation sweep for headless-addressing alignment
after Slice P design is complete. Many existing architecture docs
predate URI dispatch / typed Transport / typed AuthPolicy and need
updating. Specifically: CBAR-SUBSTRATE-ARCHITECTURE.md,
COMMAND-INFRASTRUCTURE-FIELD-MANUAL.md, MODULE-ARCHITECTURE.md,
GENOME-FOUNDRY-SENTINEL.md, INFERENCE-LANES-REALISTIC.md,
AI-COMMAND-NAMESPACE.md, OBSERVABILITY-AS-SUBSTRATE.md, plus
persona/cognition docs. Then Node-side work can land against the
clean substrate API without recreating coupling.
## What this does NOT change
- No code changes (doc-only)
- No new Slice P deliverables. The current routing primitives
already support the thin-client pattern; what lands here is the
explicit doctrine for HOW the migration unfolds and in WHAT
order, so the next implementer (future-me, a new persona, the
Node-side developer) doesn't reinvent coupling.
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…threading
The inbound symmetric of AircTransport. When a remote substrate
dispatches `airc://<this-peer>/<path>`, this handler picks up the
envelope, threads the verified sender peer_id into the local
AuthPolicy gate as `CallerIdentity::airc(sender)`, runs through
the local CommandExecutor, and replies with the typed
AircCommandResponse.
After this commit + a LAN-loopback integration test (next), the
substrate's command surface is cross-grid complete: any peer can
address any URI on any other peer's substrate, gated by each
substrate's own AuthPolicy, returning typed errors that propagate
through the wire as the same shape the local caller would have
seen.
## What lands
### `CommandExecutor::execute_with_caller(...)`
New public method accepting an optional `CallerIdentity`. The
caller threads through to `policy.gate(&decision, caller)` so the
local AuthPolicy sees who's actually invoking — substrate's own
code (None → implicitly trusted) vs a remote peer
(Some(CallerIdentity::airc(sender)) → gate evaluates against that
peer's policy row).
`execute()` is now a thin wrapper that calls
`execute_with_caller(cmd, params, None)`. All existing call sites
continue to work; the new path is opt-in for cross-grid handlers.
### `routing/command_handler.rs`
`CommandRequestHandler` — a `ConsumerAdapter` impl that:
1. **Subscribes via body_hint**: returns `COMMAND_REQUEST_BODY_HINT`
from its `body_hint()` method. airc's adapter registry routes
inbound events with this hint to `on_envelope()`.
2. **Parses the envelope** (pure, testable):
- Extracts the verified `caller_peer_id` from the envelope's
`peer_id` field (signed by airc — we trust it as the
authenticated sender)
- Pulls `reply_to` and `correlation_id` from the auto-stamped
`airc.reply_to` and `airc.correlation_id` headers
- Decodes the body as `Body::Json(AircCommandRequest)`,
surfacing typed errors on every malformed shape (missing
header, no body, binary body, bad UUID, mis-shaped JSON)
3. **Dispatches through the local executor**:
- Constructs `CommandUri::local(&request.path)` — the path the
remote dispatched maps to a local URI here
- Builds `CallerIdentity::airc(caller_peer_id)`
- Calls `executor.execute_with_caller(uri, params, Some(caller))`
- The local AuthPolicy sees the real remote caller and applies
its policy
4. **Packages the response**:
- `Ok(CommandResult::Json(v))` → `AircCommandResponse::ok(v)`
- `Ok(other)` (Handle/Stream/Lambda) → typed error
"wire-stable shapes reserved per Slice 60"
- `Err(msg)` → `AircCommandResponse::error(msg)` — gate
refusals, module errors, executor errors all propagate as
the substrate's standard String shape
5. **Replies via Airc::reply()**:
- Body: `Body::Json(serialized AircCommandResponse)`
- Headers: `HEADER_COMMAND_STATUS` (ok/error) +
`HEADER_CONTINUUM_BODY_HINT = COMMAND_RESPONSE_BODY_HINT` so
middleware can filter without parsing
### Factoring for testability
`parse_envelope`, `process_request`, and `send_reply` are all
public methods. Tests exercise:
- `parse_envelope` against hand-built TranscriptEvents (every
malformed shape produces a typed error naming the missing
piece)
- `process_request` against a real CommandExecutor + a
CannedModule (proves caller identity threads through to the
gate and the executor returns the right shape)
- `send_reply` is the edge of testability — it needs real
airc-lib, so it's only exercised by the LAN-loopback
integration test (next commit)
## Tests (+8)
routing::command_handler::tests:
- parse_envelope_extracts_caller_reply_correlation_and_request
- parse_envelope_rejects_missing_reply_to
- parse_envelope_rejects_missing_correlation_id
- parse_envelope_rejects_missing_body
- parse_envelope_rejects_binary_body
- parse_envelope_rejects_malformed_body
- parse_envelope_rejects_invalid_correlation_uuid
- handler_name_and_body_hint_match_protocol_constants
The error-shape tests are the substrate's `[[no-fallbacks-ever]]`
made structural at the wire boundary: every malformed envelope
surfaces a typed error naming the missing piece, never silently
accepted as "default."
## What lands next
- **LAN-loopback integration test** — two `Airc` instances in
process talking over a TCP socket. Persona A's AircTransport
dispatches `airc://<peer-b>/canned/op`; Persona B has this
handler registered; the typed result flows back. The proof
that the command surface is cross-grid complete.
- **Event-side parallel work** — peer-side event publisher
(emit-side) + cross-grid subscription (subscribe-side). After
that, the substrate is event-complete cross-grid too. Then the
documentation sweep (task #185); then Node returns as thin
shell per `[[substrate-complete-then-node-reintroduced-as-shell]]`.
## What this unlocks
The chokepoint Joel articulated earlier — `./jtag airc://maya/...`
from another machine actually invoking a command on Maya's
substrate — is one integration test away. The handler is the
inbound piece; AircTransport is the outbound piece; the integration
test proves the round-trip. After that, the substrate is "airc
chats with personas solid" at the command layer.
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PR #1529 spawned 3 adversarial reviewers per `[[agent-review-as-acceptable-approval]]`. Reviewer 1 (doctrine) and Reviewer 2 (architectural purity) returned with BLOCK verdicts; this commit addresses every blocking concern they raised. ## Fixed in this commit ### Reviewer 1 + 2 #1: `RouteDecision::Broadcast` silent fallback `airc_transport.rs` — the `Broadcast` arm mapped to `MentionTarget::All`, silently broadcasting to every peer in the room (not just env replicas of the target peer). Combined with the handler's lack of peer filter, `airc://maya:*/notification/send` would wake Niko too. Per `[[no-fallbacks-ever]]`: hard-error like the `Room` arm does until per-peer env-fanout has its own slice. Names the missing piece in the error so callers can act. ### Reviewer 1 #2: `unreachable!()` on dispatch hot path `airc_transport.rs` — the `Local` match arm used `unreachable!()`, which panics in debug and is UB-adjacent in release. Replaced with a typed `Err("BUG: ...")` so an invariant violation surfaces as an error, not a process panic. ### Reviewer 2 #3: handler discards `kind` + `env` `command_handler.rs::process_request` — always built `CommandUri::local(&path)`, discarding the request envelope's `kind` and `env` fields. A remote dispatch of `airc://maya:vr/widget/show` would silently run as a non-env-aware local Local call. Fixed: hard-error if `kind != "peer"` (room/broadcast semantics need their own slice) and if `env.is_some()` (env-aware local routing needs its own slice — the substrate has `EnvironmentId` typed but no per-env service registration yet). Names what's missing in each error. ### Reviewer 1 #3: missing test for non-Json result handling Added `process_request_refuses_non_json_command_result` that wires a `CannedModule` returning `CommandResult::Handle(...)` and asserts the handler refuses to send a HandleRef over the wire (returns typed error, not silent coercion). ### Reviewer 2 #4: `CallerIdentity` not threaded to interceptors `CommandInterceptor::try_route` signature changed from `(command, params)` to `(command, params, caller)`. The `CommandExecutor::dispatch` was passing caller to the gate but NOT to interceptors, so a remote `airc://this-peer/code/edit` reached `AircInterceptor` / `GridInterceptor` as if it were a local in-process invocation. That's a silent privilege-escalation seam. Updated trait + 2 production impls (`AircInterceptor`, `GridInterceptor`) + 5 test impls. Production impls don't use caller today — they ignore the new param — but the seam is in place so future capability-token / sentinel-quorum interceptors can be load-bearing without another trait change. `execute_inner` also takes `caller: Option<&CallerIdentity>` now. Threaded through from `dispatch`. ### Reviewer 2 #6: `time!` macro `_enter`-across-await foot-gun `routing/macros.rs` — the `time!` macro held `_enter = span.enter()` across `$body`, breaking `URI_STACK` if `$body` contained `.await` (the same async anti-pattern the `d1cf19dc5` dispatch fix removed from the dispatcher). Common shape `time!("infer", run_inference(...).await)` would silently break the URI ancestry chain. Renamed to `time_sync!` and documented that for async timing, callers use `.instrument(span).await` directly. The substrate-wide foot-gun is removed without adding a colliding `time_async!` (one already exists in `logging/timing.rs` with a different shape — RAII TimingGuard, not tracing span). ## Bonus refactor `CommandRequestHandler::process_request` is now `pub` and delegates to a new `process_request_via(executor, parsed)` that takes `&CommandExecutor` directly. This lets tests exercise the executor-side logic without standing up an `Arc<Airc>` placeholder (which the original test draft did with `unsafe transmute` — bad idea; replaced with clean abstraction). The `LocalGridTransport` fixture (forward work) leases the same shape to drive multi-CommandExecutor in-process tests. ## Deferred to follow-up tasks (NOT in this commit) Reviewer 2 raised two more concerns the substrate is ready to address as their own slices: - **#5 `Verdict` → `String` at dispatch boundary** — the gate's typed `Verdict` becomes a `format!`'d string in `Err(format!("forbidden: {reason}"))`. Should become a typed `SubstrateError<E>` per the design doc's "Typed end-to-end" section. Tracked. - **#2 envelope `peer` field** — `AircCommandRequest` drops the peer identity on `from_route_decision` (relies on airc routing as the only locus). Once room broadcast semantics land, the handler will need to know which peer was the original target. Tracked. These are real concerns but each is its own slice; landing them here would balloon the touch-up. Both filed as follow-up. ## Tests All routing + command_executor + command_interceptor tests pass. The new tests: - `process_request_rejects_room_kind` - `process_request_rejects_broadcast_kind` - `process_request_rejects_env_targeted_dispatch` - `process_request_refuses_non_json_command_result` Each pins a `[[no-fallbacks-ever]]` property at a specific seam. A future refactor that re-introduces silent substitution at any of these points fails one of these tests loudly. card: fa25de80-0c1b-4de5-8ff9-524d95e303cd Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PR #1529 reviewer 3 (CI + test coverage) returned with APPROVE-WITH-CONCERNS leaning BLOCK. Three real coverage gaps: 1. `AircTransport::dispatch` shipped with 0 executing tests — only stub `#[tokio::test]`s that called `route()` and matched on variants. Every refusal path (Local-guard, Room error, Broadcast silent-fallback refusal, Peer-name whois pending, body-decode errors) was unreachable from a unit test because `dispatch` needed a real `Arc<Airc>`. 2. `CommandRequestHandler::process_request` ships `pub` for testability, but no test asserts the `CallerIdentity` actually reaches the AuthPolicy gate. The headline-of-the-PR caller-threading branch was untested. 3. Two stub `#[tokio::test]`s with no assertable behavior (`dispatch_with_local_decision_is_a_bug` and `dispatch_with_room_decision_returns_typed_error`). The reviewer's suggested fix was the right shape: extract testable free functions from `dispatch` so the logic is reachable without spinning up airc. Doing exactly that. ## What changed ### `AircTransport::dispatch` extracted into testable free functions `resolve_outbound(decision, params) -> Result<(MentionTarget, AircCommandRequest), String>` — pre-flight conversion. Every refusal branch (Local-guard, Peer-name whois-pending, Broadcast silent-fallback refusal, Room semantics not-implemented, BUG-guard) lives here. Pure function. Testable. `decode_reply(body: Option<Body>) -> Result<Value, String>` — reply unpacking. Every reply error (no body, Binary body, malformed JSON, Error variant propagation) lives here. Pure function. Testable. `dispatch` is now ~30 lines: `resolve_outbound → serialize body → build headers → airc.request → await_reply → decode_reply`. The only airc-touching code left is the request/await pair, which is exactly what the LAN-loopback integration test (#188) covers. ### New executing tests (+10) routing::airc_transport::tests: - `resolve_outbound_local_is_a_bug` — Local refusal at the transport boundary - `resolve_outbound_room_returns_typed_not_implemented_error` — Room rejection echoes room_id - `resolve_outbound_broadcast_refuses_silent_fallback` — proves the env-wildcard broadcast won't silently map to All (the reviewer 1 + 2 fix locked in code, now locked in a test) - `resolve_outbound_peer_name_pending_whois_resolver` — name peers error pointing at whois slice - `resolve_outbound_peer_uuid_produces_target_and_request` — UUID peer happy path - `decode_reply_none_body_errors_with_actionable_message` - `decode_reply_binary_body_errors_with_shape_mismatch` - `decode_reply_malformed_json_errors_with_decode_context` - `decode_reply_ok_response_returns_value` — Ok happy path - `decode_reply_error_response_returns_error_propagating_message` — remote error propagates verbatim routing::command_handler::tests: - `process_request_via_threads_caller_into_gate` — uses a ClosurePolicy that captures the caller it receives, dispatches via process_request_via, asserts captured.peer_id matches the envelope's caller_peer_id and source is `Airc`. Closes the silent-privilege-escalation seam at the test layer. ### Stub tests removed The two `#[tokio::test]`s that didn't assert any behavior (`dispatch_with_local_decision_is_a_bug`, `dispatch_with_room_decision_returns_typed_error`) replaced by the real `resolve_outbound_*` tests above. ## What's still deferred Reviewer 3 also flagged `send_reply` as untested. That's genuinely prerequisite-locked — calling `send_reply` requires a real `Arc<Airc>` (it's the `airc.reply()` invocation). It will be exercised by the LAN-loopback integration test (#188) once the `TwoAircLoopback` fixture (#187) lands. Documented in `send_reply`'s doc comment as the deferred coverage target, so a future reader knows. card: fa25de80-0c1b-4de5-8ff9-524d95e303cd Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ent subscription
The event-side parallel of `AircCommandProtocol`. Per
[[events-are-the-organic-rtos-substrate]]: commands and events are
two **temporal shapes** of the same URI-addressable coordination
primitive. Slice P shipped the command shape; this is the first
event-side commit, mirroring the command-protocol structure
exactly so the substrate's pattern stays uniform.
After this commit + AircEventTransport (next) + the peer-side
event publisher (commit after), the substrate's CBAR coordination
floor is event-complete cross-grid. That's the gate that lets
widgets, sentinels, Ares, foundry, brain visualizations actually
subscribe to remote substrates and consume their typed events —
per [[addressable-cognition-makes-triggers-trivial]]: any
subcomponent anywhere responds to any other concern by merely
subscribing.
## The three-message protocol
Event subscription is a **3-message** dance on the wire:
1. **Subscribe** — caller dispatches
`airc://<peer>/events/<topic>/subscribe`. AircEventTransport
packages `AircEventSubscribe` and uses `Airc::request`
(same primitive as commands). The peer-side publisher acks
with `AircEventSubscribeAck { subscription_id }`.
2. **Deliver** — peer publishes `AircEventDeliver` frames as
events fire. Each carries `subscription_id` so the caller's
stream demultiplexes when multiple subscriptions are
active. Sequence numbers let the caller detect drops.
3. **Unsubscribe** — caller dispatches
`airc://<peer>/events/<topic>/unsubscribe` with
`AircEventUnsubscribe { subscription_id }`. Peer tears the
subscription down. Acked with `AircEventUnsubscribeAck`.
## Why three messages, not pub/sub directly on airc rooms
airc's `Airc::subscribe()` is room-scoped — the personas use it
for chat. The event protocol shape adds what the substrate
specifically needs:
- **Typed topics per URI** — substrate emits at
`airc://maya/cognition/analyze/complete`, not at a chat room.
The topic-URI-namespace is what makes the substrate
composable.
- **Server-side filtering** — caller subscribes with an
optional `filter` predicate; peer filters before sending so
the wire doesn't carry every event.
- **Per-subscription handles** — caller might hold N
subscriptions concurrently; each gets its own UUID for
targeted unsubscribe.
- **Sequence + drop detection** — per-subscription monotonic
sequence numbers let the caller detect dropped events.
airc's underlying transport (LAN/grid/relay) carries the
messages; the protocol shape is the substrate's contract on top.
## What this commit ships
- **`AircEventSubscribe { topic, filter }`** — subscribe-request
envelope. `filter` omitted from JSON when None.
- **`AircEventSubscribeAck { subscription_id, topic }`** — peer
ack with the per-subscription UUID.
- **`AircEventDeliver { subscription_id, topic, sequence,
payload }`** — single event delivery from peer to subscriber.
- **`AircEventUnsubscribe { subscription_id }`** — caller's
unsubscribe request.
- **`AircEventUnsubscribeAck { subscription_id, closed: bool }`**
— peer ack, `closed: false` is idempotent (already gone),
matching `data/query-close` semantics.
- **Header constants** — `HEADER_EVENT_TOPIC`,
`HEADER_EVENT_KIND` (`"subscribe" | "deliver" |
"unsubscribe" | "ack"`), `HEADER_EVENT_SUBSCRIPTION_ID`.
- **Body hints** — `EVENT_{SUBSCRIBE,DELIVER,UNSUBSCRIBE,ACK}_BODY_HINT`
with `v1` version pins.
## What lands next
- **`AircEventTransport`** — the caller-side. Holds
`Arc<airc_lib::Airc>`. Implements an event-subscribe shape
(the Transport trait extension) that returns a typed
`EventStream<AircEventDeliver>`. Internally: send subscribe
via `Airc::request`, await ack, then poll for Deliver
frames matching the subscription_id.
- **Peer-side event publisher** — `ConsumerAdapter` registered
against `EVENT_SUBSCRIBE_BODY_HINT`. Subscribes to local
`Events::emit()` traffic, filters by topic, fans out to
every active subscription as `AircEventDeliver` frames via
`Airc::reply()`. Per-subscription state lives behind a
`HandleRef` shape so unsubscribe is targeted.
- **`Events::subscribe::<E>(uri)`** consumer-level typed
surface — the row above wire-level, where personas/widgets
write `Events::subscribe::<ChatMessages>("airc://room:..../chat")`
and get a typed `EventStream<ChatMessage>` without seeing
the protocol envelopes.
## Tests (+9)
routing::airc_event_protocol::tests:
- `subscribe_round_trips_json`
- `subscribe_omits_filter_when_none` — `None` filter skipped
on the wire (smaller envelopes for the common case)
- `subscribe_ack_round_trips`
- `deliver_round_trips`
- `unsubscribe_round_trips`
- `unsubscribe_ack_round_trips_active` — `closed: true`
- `unsubscribe_ack_round_trips_idempotent` — `closed: false`
pin idempotency contract
- `deliver_sequence_distinguishes_drops_per_subscription` —
proves the sequence field round-trips so the caller's drop
detection works at the wire level
- `header_names_are_stable_strings` — guards against silent
rename of header / body_hint constants
## Doctrine alignment
- **`[[no-fallbacks-ever]]`** — every envelope is typed; no
ambiguous "maybe-event" wire shape. Subscribe failure surfaces
as a typed error (next commit's AircEventTransport will
collapse those to the canonical Result shape).
- **`[[airc-headers-are-the-routing-layer]]`** — topic + kind +
subscription_id all on the wire as headers so middleware
filters without parsing body.
- **`[[commands-are-kernel-level-and-compose]]`** — events ride
the same Transport trait + Airc::request primitive as
commands. Different temporal shape; same routing chain; same
auth gate; same observability.
- **Compression** — ONE protocol mirrors the command protocol
exactly. Future reader looks at AircCommandProtocol and
AircEventProtocol side by side and sees the pattern
immediately. Adding a third coordination shape (e.g. typed
cell-result streaming if the Slice 60 Stream variant ever
lands) follows the same mold.
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…cription The event-side parallel of AircTransport. After AircEventProtocol (67199b2) shipped the typed wire shape, this commit ships the caller-side runtime: turn an `airc://<peer>/events/<topic>` URI into a subscribe round-trip and a typed `EventSubscription` handle that yields matching `AircEventDeliver` frames as they arrive. Combined with the peer-side event publisher (next commit), this closes the event-side of Slice P — substrate cognition emitting on one continuum becomes substrate cognition consumable on another, via the same `Airc::request` primitive commands ride. ## What this commit ships - **`AircEventTransport`** struct holding `Arc<airc_lib::Airc>` + configurable deadline. Cheap clone; typically shared. - **`EventSubscription`** handle returned from `subscribe()` — carries `subscription_id`, `topic`, and a `tokio::sync::mpsc::Receiver<AircEventDeliver>` that yields matching delivery frames. Per-subscription filter task is spawned to translate the airc-wide stream into a typed per-subscription stream. - **`subscribe(target_peer, topic, filter)`** — sends `AircEventSubscribe` via `Airc::request`, awaits `AircEventSubscribeAck`, spawns the filter task, returns the handle. All typed; no `Value` leakage at the caller boundary. - **`unsubscribe(target_peer, subscription_id)`** — sends `AircEventUnsubscribe` via `Airc::request`, awaits `AircEventUnsubscribeAck`, returns the `closed: bool` verdict (false signals idempotent already-gone, matching `data/query-close` semantics). ## Testable-seams pattern preemptively applied Per PR #1529 reviewer 3's coverage doctrine, every refusal branch is factored as a `pub` free function — unit-testable WITHOUT spinning up real airc. The airc-touching methods are thin wrappers around these: - `resolve_subscribe` — builds `(MentionTarget, Headers, Body)` for the outbound subscribe envelope; refuses empty topic upfront with a typed error citing `[[no-fallbacks-ever]]`. - `resolve_unsubscribe` — same for unsubscribe. - `decode_subscribe_ack` — unpacks reply body, typed errors for missing body / binary body / malformed JSON. - `decode_unsubscribe_ack` — same for unsubscribe-reply. - `decode_deliver_frame` — extracts `AircEventDeliver` from a `TranscriptEvent`, surfacing typed errors for each malformed case. - `matches_subscription` — pure predicate the filter task uses to drop non-matching frames cheaply (HashMap get + string compare, no body parse) before invoking decode. Internalized from earlier reviewer feedback (e4f1865): "`pub` for testability is a promise; tests are the payment" — every pure free function lands with a test covering happy path + every named failure mode. ## Tests (+17) `routing::airc_event_transport::tests`: resolve_subscribe / unsubscribe envelope construction - `resolve_subscribe_with_topic_and_filter_produces_envelope` - `resolve_subscribe_with_none_filter_omits_filter_in_body` — smaller wire envelopes for the common case - `resolve_subscribe_empty_topic_refuses` — refusal cites the doctrine, names what's missing - `resolve_unsubscribe_packages_subscription_id_in_body_and_header` ack decoding (subscribe + unsubscribe) - `decode_subscribe_ack_round_trips` - `decode_subscribe_ack_refuses_missing_body` - `decode_subscribe_ack_refuses_binary_body` - `decode_subscribe_ack_refuses_malformed_json` - `decode_unsubscribe_ack_round_trips_active` - `decode_unsubscribe_ack_round_trips_idempotent` — `closed: false` contract pinned deliver frame decoding - `decode_deliver_frame_round_trips_a_real_deliver` - `decode_deliver_frame_refuses_missing_body` - `decode_deliver_frame_refuses_binary_body` filter predicate (per-subscription demux) - `matches_subscription_yes_for_matching_id_and_body_hint` - `matches_subscription_no_for_wrong_subscription_id` - `matches_subscription_no_for_non_deliver_body_hint` — proves a command-request envelope on the same airc stream is dropped - `matches_subscription_no_for_missing_subscription_id_header` `subscribe()`/`unsubscribe()` themselves (airc-touching) covered by the LAN-loopback integration test once #187 `TwoAircLoopback` fixture lands (#188). ## Doctrine alignment - **`[[no-fallbacks-ever]]`** — empty topic refused with a typed error citing the doctrine (not silently mapped to a wildcard); every decode failure surfaces as a typed `Err`; subscription-id mismatch drops the frame rather than guessing. - **`[[events-are-the-organic-rtos-substrate]]`** — events use the exact same `Airc::request` + `await_reply` primitive as commands, ride the same airc transport, dispatched through the same routing chain. Different temporal shape (subscribe → many deliver → unsubscribe), same kernel. - **`[[airc-headers-are-the-routing-layer]]`** — the filter task's cheap predicate path uses `HEADER_CONTINUUM_BODY_HINT` + `HEADER_EVENT_SUBSCRIPTION_ID` headers to demux before parsing body; middleware can filter at the same layer. - **Test-fixtures-are-system-primitives** — preemptive testable-seams factoring means every refusal branch becomes a documented public function with named coverage. No `#[cfg(test)]` throwaway helpers; the pure functions are part of the API. - **Compression** — `AircEventTransport` mirrors `AircTransport` exactly: same `resolve_*` / `decode_*` shape, same arc-of-airc field, same deadline-configurable constructor. A reader sees the two side by side and grasps the pattern immediately. Adding a third coordination shape (typed streaming cell-results, if Slice 60 Stream variant ever lands) follows the same mold. ## What lands next - **Peer-side event publisher** — `ConsumerAdapter` registered against `EVENT_SUBSCRIBE_BODY_HINT`. Mints `subscription_id` on receiving subscribe, registers locally, hooks into local `Events::emit()` for matching topics, fans out as `AircEventDeliver` frames to the subscriber's peer via `Airc::reply()`. - **`Transport` trait extension** — adds a `subscribe()` method so the dispatcher's match can route subscription URIs (e.g. `airc://<peer>/events/<topic>/subscribe`) through this transport via the same chain as commands. - **`Events::subscribe::<E>(uri)`** consumer-level typed API — the row above wire-level. Personas/widgets call `Events::subscribe::<ChatMessages>("airc://room:.../chat")` and get a typed `EventStream<ChatMessage>` without ever seeing the protocol envelopes. - **LAN-loopback integration test** — paired with the command surface integration test (#188) once `TwoAircLoopback` fixture lands. card: fa25de80-0c1b-4de5-8ff9-524d95e303cd Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…eer-side event publisher
The state machine + testable seams for the peer-side cross-grid
event publisher. Splits the peer-side work into two reviewable
commits: this one ships everything that doesn't touch airc (state
+ pure functions + tests); the next commit will wrap them in the
two ConsumerAdapters + the publish() fan-out method.
Mirrors `CommandRequestHandler`'s pattern exactly. The reader sees
`parse_subscribe_envelope` and `CommandRequestHandler::parse_envelope`
side by side and grasps the symmetry immediately.
## What this commit ships
### State machine (`EventPublisherState`)
- `register(subscriber, topic, filter) -> Result<Uuid, String>`
— mints subscription_id, inserts active entry. Refuses empty
topic upfront with a typed error citing
`[[no-fallbacks-ever]]`.
- `unregister(subscription_id) -> bool` — removes; returns
whether the entry existed (matches `closed: bool` contract on
`AircEventUnsubscribeAck`).
- `lookup_matching(topic, payload) -> Vec<MatchedSubscription>`
— filter pass for fan-out. Each `MatchedSubscription` carries
`(subscription_id, subscriber_peer_id, sequence)` so the
publish path (next commit) builds Deliver frames without
re-touching the registry.
- Per-subscription monotonic sequence — `AtomicU64` bumped at
lookup time, so caller-side drop detection works without
coordination.
- `parking_lot::RwLock` — operations are short-lived synchronous
HashMap ops, no .await spans.
### Pure free functions (the testable seams)
- `parse_subscribe_envelope(envelope) -> Result<ParsedSubscribe, AdapterError>`
- `parse_unsubscribe_envelope(envelope) -> Result<ParsedUnsubscribe, AdapterError>`
- `build_subscribe_ack(subscription_id, topic) -> Result<(Headers, Body), String>`
- `build_unsubscribe_ack(subscription_id, closed) -> Result<(Headers, Body), String>`
- `build_deliver_frame(deliver) -> Result<(Headers, Body), String>`
- `matches_filter(filter, payload) -> bool`
Every failure mode is named in the error message + tested. The
ConsumerAdapter wrappers (next commit) become thin shells around
these.
### Filter semantics (v1 — conservative, expandable)
- `None` filter → matches everything.
- `Some({})` empty-object filter → matches everything (explicit
"no constraints").
- `Some({k: v, ...})` → AND of equality predicates on top-level
payload fields.
- Anything else → does NOT match (refuses to guess intent per
`[[no-fallbacks-ever]]`).
Future predicate-tree shapes ($gt/$in/$regex) need their own
slice with explicit doctrine alignment — v1 ships the smallest
useful shape we can defend without inventing a query language.
## Design choice: room-broadcast Deliver, header-demux at subscriber
The peer-side will send Deliver frames as room-broadcast via
`Airc::publish` (next commit) — subscribers demux by
`HEADER_EVENT_SUBSCRIPTION_ID`. Same shape airc chat uses;
subscription_id is a UUID so non-subscribers can't accidentally
match. Per-peer point-to-point is an optimization for a later
slice, not a correctness need.
## Tests (+26)
`routing::airc_event_publisher::tests`:
EventPublisherState — registration + lookup
- `register_then_lookup_finds_the_subscription`
- `register_empty_topic_refuses` — refusal cites doctrine + names
missing piece; state unchanged on refuse
- `unregister_returns_true_for_active_subscription`
- `unregister_returns_false_for_unknown_id_idempotent_contract`
- `lookup_matching_excludes_other_topics`
- `lookup_matching_applies_filter`
- `lookup_matching_bumps_per_subscription_sequence_monotonically`
- `lookup_matching_sequences_are_per_subscription_not_global`
— proves two subs on the same topic have independent counters
matches_filter predicate
- `matches_filter_none_matches_everything`
- `matches_filter_empty_object_matches_everything`
- `matches_filter_object_requires_equality_on_every_field` —
missing field + value mismatch both rejected
- `matches_filter_payload_not_object_refuses`
- `matches_filter_non_object_filter_refuses_not_guesses`
parse_subscribe_envelope
- `parse_subscribe_envelope_round_trips`
- `parse_subscribe_envelope_rejects_missing_reply_to`
- `parse_subscribe_envelope_rejects_missing_correlation_id`
- `parse_subscribe_envelope_rejects_missing_body`
- `parse_subscribe_envelope_rejects_binary_body`
- `parse_subscribe_envelope_rejects_malformed_body`
parse_unsubscribe_envelope
- `parse_unsubscribe_envelope_round_trips`
- `parse_unsubscribe_envelope_rejects_missing_body`
build_*_ack + build_deliver_frame
- `build_subscribe_ack_stamps_protocol_headers_and_round_trips_body`
- `build_unsubscribe_ack_active_preserves_closed_true`
- `build_unsubscribe_ack_idempotent_preserves_closed_false`
- `build_deliver_frame_stamps_protocol_headers_and_round_trips_body`
cross-boundary contract (caller ↔ peer symmetry)
- `build_deliver_frame_passes_caller_side_matches_subscription`
— proves the frame the peer-side builds satisfies the
caller-side `AircEventTransport::matches_subscription`
predicate + decodes via `decode_deliver_frame`. Pins the
symmetry contract across the routing module boundary so a
future refactor of either side that breaks the other fails
loudly here.
## What lands next
- **`EventSubscribeAdapter`** + **`EventUnsubscribeAdapter`** —
thin `ConsumerAdapter` impls that share an
`Arc<EventPublisherState>`. parse_*, delegate to
state.register/unregister, build_*_ack, reply via `Airc::reply`.
- **`AircEventPublisher::publish(topic, payload)`** — fan-out.
Looks up matching subscriptions, builds Deliver per match
(sequence already bumped), sends via `Airc::publish` with the
subscription_id header. Returns count of fanouts for telemetry.
## Doctrine alignment
- **`[[no-fallbacks-ever]]`** — empty topic refused with typed
error citing the doctrine. Non-object filters refused. Every
decode failure surfaces as a typed Err.
- **Test-fixtures-are-system-primitives** — every state-machine
primitive (register/unregister/lookup) + every pure function
has a public API + named test coverage.
- **`[[events-are-the-organic-rtos-substrate]]`** — the state
machine is shaped around the substrate's per-subscription
identity (UUID + sequence + filter + subscriber) so events ride
the substrate's coordination floor with the same isolation
commands have.
- **Compression** — the parse/build functions mirror
`CommandRequestHandler::parse_envelope` exactly. A reader who
understands the command side immediately understands the event
side. Adding a third coordination shape (typed streaming
cell-results) follows the same mold.
card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…umerAdapter shells The wire-binding shells around EventPublisherState (commit 803ab17). Each adapter is a thin ConsumerAdapter impl that: 1. Parses the incoming TranscriptEvent via the relevant pure function (parse_subscribe_envelope / parse_unsubscribe_envelope) 2. Delegates to the state machine (register / unregister) 3. Builds the ack via the relevant pure function (build_subscribe_ack / build_unsubscribe_ack) 4. Replies via `Airc::reply` Mirrors `CommandRequestHandler` exactly. The interesting logic lives in the pure functions (covered by 26 tests in the previous commit); the adapter is the wire-binding glue. ## What this commit ships - **`EventSubscribeAdapter`** — ConsumerAdapter claiming `EVENT_SUBSCRIBE_BODY_HINT`. `Arc<Airc> + Arc<EventPublisherState>`. `body_hint()` / `name()` accessors stable. - **`EventUnsubscribeAdapter`** — ConsumerAdapter claiming `EVENT_UNSUBSCRIBE_BODY_HINT`. Shares an `Arc<EventPublisherState>` with the subscribe adapter so unsubscribe targets the same registry subscribe populated. - **`process_subscribe`** / **`process_unsubscribe`** — public associated functions on each adapter that drive the three middle steps (parse → state → build) without touching airc. Tests lease them directly; the `on_envelope` trait method just adds the airc.reply call around them. ## Why two adapters, not one The airc adapter registry routes by `body_hint`. Subscribe and unsubscribe have distinct hints so each needs its own registration. They share an `Arc<EventPublisherState>` so register/unregister target the same registry. ## Tests (+7) `routing::airc_event_adapters::tests`: - `process_subscribe_registers_and_returns_ack_with_subscription_id` — the happy path; verifies state side effect + ack body/headers - `process_subscribe_refuses_empty_topic_with_typed_error` — refusal carries the doctrine cite; state unchanged on refuse - `process_subscribe_threads_filter_into_registry` — pins that server-side filtering works end-to-end (subscribe filter → `lookup_matching` accepts/rejects matching payload). Closes "future refactor drops the filter mid-pipeline" gap. - `process_unsubscribe_removes_active_subscription_and_acks_closed_true` — subscribe via SubAdapter, unsubscribe via UnSubAdapter, verify side effects via shared state - `process_unsubscribe_unknown_id_returns_closed_false_idempotent` — pins idempotent-ack contract - `adapter_names_and_body_hints_match_protocol_constants` — wire contract guard against silent rename - `subscribe_and_unsubscribe_adapters_target_same_registry_via_shared_state` — architectural property: shared Arc<state> contract pinned via observable side effect The `on_envelope` trait methods themselves (airc-touching) are covered by the LAN-loopback integration test once #187 `TwoAircLoopback` lands. ## What lands next The remaining piece of the peer-side: **`publish(topic, payload)`** — the fan-out method that: - Looks up matching subscriptions via `lookup_matching` - Builds an `AircEventDeliver` per match (sequence already bumped by lookup) - Sends each via `Airc::publish` with the subscription_id header (subscribers demux via `AircEventTransport::matches_subscription`) That closes the event-side cross-grid round-trip. Then the LAN-loopback integration test (#188 covers commands; an event counterpart follows). ## Doctrine alignment - **`[[no-fallbacks-ever]]`** — empty topic refused with typed error (already covered at state level; the adapter propagates). - **Test-fixtures-are-system-primitives** — `process_subscribe` / `process_unsubscribe` exposed `pub` so tests drive them without spinning up airc. Same pattern as `CommandRequestHandler::process_request_via` from PR #1529. - **Compression** — the two adapters share the state Arc, and their structure mirrors each other line-for-line. A reader sees one and grasps the other immediately. - **`[[airc-headers-are-the-routing-layer]]`** — body_hint determines which adapter receives the envelope; the wire-level contract is the body_hint constant. Adapter-name constant is a stable identity for the registry, not the routing key. card: fa25de80-0c1b-4de5-8ff9-524d95e303cd Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… peer-side event surface The third + final commit of the peer-side event publisher slice (#193). Composes the state machine (commit 803ab17) + the two ConsumerAdapter wrappers (commit fba9682) into a single facade exposing `publish(topic, payload)` — the entry point substrate cognition uses to fan an event out to every matching subscriber. After this lands the peer-side event surface is complete: inbound subscribe → state.register → ack → events emitted via publisher.publish(topic, payload) → looked up via state.lookup_matching → built into Deliver via build_deliver_frame → sent via Airc::publish. Subscribers (running AircEventTransport from commit a6a534c) pick up via the airc event stream, demuxed by HEADER_EVENT_SUBSCRIPTION_ID. The event-side cross-grid round-trip is whole — substrate cognition on continuum-A can be consumed on continuum-B with the same routing/auth/observability chain commands ride. ## What this commit ships ### `AircEventPublisher` facade Composes `Arc<Airc> + Arc<EventPublisherState>`. Two constructors: - `new(airc)` — fresh empty state. - `with_state(airc, state)` — adopt an existing state (useful in tests + when the ConsumerAdapters were constructed first). - `state()` — borrow the shared Arc so the two adapters share the same registry the publisher reads. - `publish(topic, payload) -> Result<usize, String>` — fan-out entry point. Returns count of fanouts; zero matches is Ok(0), not an error. ### Pure free function: `build_publish_envelopes` `publish()` is factored into two layers: - `Self::build_publish_envelopes(state, topic, payload) -> Result<Vec<(MatchedSubscription, Headers, Body)>, String>` — the pure composition: looks up matches, builds Deliver per match. Tests exercise this directly without airc. - `publish()` itself is the thin shell that iterates the envelopes and sends each via `Airc::publish`. Per-send failure surfaces as a typed error that names successful fanouts so far + the failing subscription — no silent partial-fanout drift per `[[no-fallbacks-ever]]`. ### Failure-mode error shape - 0 matches → `Ok(0)`. Silent topics + filter mismatches are valid steady-state. - `build_deliver_frame` error (would only happen on serde failure) → `Err("build_publish_envelopes: ...")` naming the subscription. - Per-send airc error → `Err("airc.publish failed after N successful fanouts of TOTAL (sub=UUID): ...")` so telemetry + operator can pinpoint the lossy lane. ## Tests (+4 → 30 publisher tests total) `routing::airc_event_publisher::tests`: - `build_publish_envelopes_empty_when_no_subscriptions_match` — pins the 0-match steady-state contract - `build_publish_envelopes_one_per_match_with_demuxable_headers` — proves: (1) one envelope per matched subscription, (2) headers carry the correct subscription_id for demux, (3) the body round-trips back through AircEventDeliver, (4) other topics correctly excluded - `build_publish_envelopes_respects_filter` — server-side filter contract pinned end-to-end (filter accepts info, rejects warn) - `build_publish_envelopes_bumps_per_subscription_sequence_across_calls` — sequence monotonicity contract caller-side drop-detector relies on, pinned across the publish path The `publish()` method itself (airc-touching) covered by the LAN-loopback integration test once #187 lands. ## Doctrine alignment - **`[[no-fallbacks-ever]]`** — per-send failure surfaces the partial-fanout count instead of silently continuing. Filter semantics from EventPublisherState propagate to the publish path unchanged. - **`[[events-are-the-organic-rtos-substrate]]`** — events ride `Airc::publish(FrameKind::Event)` — same airc primitive as any other structured envelope. The peer-side publisher is just an ordinary airc consumer registered against a known body_hint, with no special transport machinery. - **Test-fixtures-are-system-primitives** — `build_publish_envelopes` exposed `pub` as a system primitive, NOT a `#[cfg(test)]` helper. Same pattern as PR #1529's `process_request_via` + this commit's testable-seams discipline. - **Compression** — `AircEventPublisher` is the symmetric of `AircEventTransport` on the peer side, and `EventPublisherState` is the symmetric of the per-subscription state the caller's filter task holds. A reader sees the two sides next to each other and grasps the wire symmetry immediately. - **Substrate primitive layering** — the facade reuses the same state Arc as the ConsumerAdapters; the ConsumerAdapters reuse the same parse/build pure functions as the facade. ONE decision (subscription state, envelope shape) lives in ONE place across all three commits — Joel's compression principle at module scope. card: fa25de80-0c1b-4de5-8ff9-524d95e303cd Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…gery defense, auth gate, missing tests Two adversarial reviewers (commits 67199b2→49053c240 round-up) returned **BLOCK with 4 concerns each + 3 nits**. Every concern legit; every fix below. Reviewer 1 lens — `[[no-fallbacks-ever]]` + `pub`-for-testability promise. Reviewer 2 lens — load-bearing context threading + caller/peer symmetry. The patterns mirror the prior round's command-side BLOCKs almost verbatim; the event arc was reproducing them silently. This commit closes them. ## Runtime BLOCKs (silent failure, forgery, leak) ### BLOCK R1.1 + R2.3 — silent failure + filter-task leak `AircEventTransport::subscribe` spawned the filter task with `airc.subscribe().await` INSIDE the task. Two failure modes: 1. If `airc.subscribe()` failed, the task silently returned. The caller had already received `Ok(EventSubscription)` and waited on a `deliveries` receiver that would never fire. Peer-side held a registration the caller could never observe → silent under-delivery. 2. The spawn loop's only exit-on-receiver-drop path was `tx.send(...).is_err()` — which only fired AFTER a matching frame arrived. Quiet topics never woke the task; dropped receivers leaked the airc::EventStream + task indefinitely. Fix: - **Open the airc EventStream BEFORE the peer request.** If the stream open fails, return `Err` to the caller before any peer-side state change. Mirrors `Airc::request`'s "arm the reply stream before sending the request" discipline. - **`tokio::select!` between `event_stream.next()` and `tx.closed()`.** Receiver drop causes immediate task exit regardless of topic activity. `biased` keeps the close check first. ### BLOCK R2.2 — Deliver-frame forgery `matches_subscription` checked `body_hint + subscription_id` headers but NOT `event.peer_id` (the airc-verified sender). Combined with `AircEventPublisher::publish`'s use of `PublishTarget::CurrentRoom` (room broadcast), every peer in the room saw Deliver frames and could re-stamp identical headers on a forged frame. The subscriber's filter task would accept it. Concrete attack: subscribe to Maya at `cognition/score/persona-scored`. Eve sees the ack, extracts subscription_id, publishes a forged `AircEventDeliver` with that id. Subscriber's filter forwards the forged payload into cognition. Fix: - **`EventSubscription` records `publisher_peer_id`** captured from `target_peer` at subscribe time. - **`matches_subscription(event, subscription_id, expected_publisher)`** rejects frames where `event.peer_id != expected_publisher` BEFORE the cheap header checks. The signed airc sender is the trust anchor; the subscription_id is just for demux among many subs to the same publisher. ## Architectural BLOCK (auth gate) ### BLOCK R2.1 — no auth gate on event subscribe The command-side path threads `CallerIdentity::airc(verified_sender)` into `executor.execute_with_caller` → `policy.gate(...)`. The event-side path captured `caller_peer_id` in `ParsedSubscribe` then handed it to `EventPublisherState::register` with NO policy gate between. Any peer reachable via airc could subscribe to ANY topic — including internal substrate signals — and the publisher would fan out matching events to them. Fix: - **`EventSubscribeAdapter` carries `Arc<dyn AuthPolicy>`, defaulting to `AllowAllPolicy`.** Builder-style `with_policy()` swaps in operator-installed policy at boot (mirrors `CommandExecutor::with_policy`). - **`process_subscribe` builds `CallerIdentity::airc(...)` + `RouteDecision::Local { path: "events/<topic>/subscribe", .. }` and calls `policy.gate()` BEFORE `state.register`.** Forbidden / Deferred verdicts surface as typed `AdapterError::Consumer` with the reason + caller + topic in the message. No silent fallback. - **The synthetic URI path `events/<topic>/subscribe` is stable contract** — pinned by `process_subscribe_decision_path_is_stable` so a future refactor can't silently change the path and break prefix-based allow/deny rules. - **`EventUnsubscribeAdapter` intentionally has NO gate.** Unsubscribe is idempotent and we want peers whose subscribe access was revoked to still be able to tear down their stuck registrations. Documented in the type doc. ## Tests (+18 new, all 8 BLOCKs covered) Per `[[test-fixtures-are-system-primitives]]` + "tests are the payment": every BLOCK above has a named test pinning the contract. `routing::airc_event_transport::tests`: - `decode_unsubscribe_ack_refuses_missing_body` (R1.4) - `decode_unsubscribe_ack_refuses_binary_body` (R1.4) - `decode_unsubscribe_ack_refuses_malformed_json` (R1.4) - `matches_subscription_no_for_wrong_publisher_forgery_defense` (R2.2) - Existing `matches_subscription_*` tests updated to thread the publisher_peer_id third arg `routing::airc_event_publisher::tests`: - `parse_subscribe_envelope_rejects_invalid_reply_to_uuid` (R1.3) - `parse_subscribe_envelope_rejects_invalid_correlation_uuid` (R1.3) - `parse_unsubscribe_envelope_rejects_missing_reply_to` (R1.2) - `parse_unsubscribe_envelope_rejects_missing_correlation_id` (R1.2) - `parse_unsubscribe_envelope_rejects_invalid_reply_to_uuid` (R1.2) - `parse_unsubscribe_envelope_rejects_invalid_correlation_uuid` (R1.2) - `parse_unsubscribe_envelope_rejects_binary_body` (R1.2) - `parse_unsubscribe_envelope_rejects_malformed_body` (R1.2) - `build_subscribe_ack_passes_caller_side_decode_subscribe_ack` (R2.4) - `build_unsubscribe_ack_active_passes_caller_side_decode` (R2.4) - `build_unsubscribe_ack_idempotent_passes_caller_side_decode` (R2.4) - Existing `build_deliver_frame_passes_caller_side_matches_subscription` updated to thread publisher_peer_id `routing::airc_event_adapters::tests`: - `process_subscribe_threads_caller_into_gate` (R2.1) — mirrors `command_handler::process_request_via_threads_caller_into_gate` - `process_subscribe_refuses_when_policy_forbids` (R2.1) — Forbidden verdict surfaces typed error + state unchanged - `process_subscribe_decision_path_is_stable` (R2.1) — pins the synthetic URI shape so prefix-based policies don't silently break **81/81 event-side tests pass** post-fix on `fa25de80/slice-p-grid-addressing` branch. ## Nits - `airc_event_adapters.rs` — removed dead `let _ = Value::Null;` line. - `airc_event_transport.rs:166` — fixed indent regression on `self` in `with_deadline`. - `airc_event_transport.rs:401-404` — replaced silent `continue` on malformed Deliver frame with `tracing::warn!` carrying subscription_id, publisher peer_id, and the typed decode error. Closes the "BLOCK R2.2 forgery becomes unfindable bug post-mitigation" gap per `[[observability-is-half-the-architecture]]`. ## Doctrine - `[[no-fallbacks-ever]]` — every silent path replaced with a typed Err - `[[every-error-is-an-opportunity-to-battle-harden]]` — auth gate + forgery defense are real fixes, not test workarounds - `[[substrate-gate-vs-persona-cognition]]` — gate decides access, NEVER persona response - `[[airc-headers-are-the-routing-layer]]` — publisher peer_id + body_hint + subscription_id all on the wire as substrate routing primitives - `[[observability-is-half-the-architecture]]` — warn log on malformed frame matching subscription card: fa25de80-0c1b-4de5-8ff9-524d95e303cd Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
joelteply
added a commit
that referenced
this pull request
Jun 6, 2026
* feat(probes): time_probe! macro — safe one-line async timing for cognition seams
Per Joel 2026-06-06 `[[refine-tools-as-you-use-them]]`: I hit this
friction in the silence-affordance + identity-grounding work and
sat on it. Every async timing site in the cognition path was an
`.instrument(info_span!("time", name=..., probe_class="timing")).await`
ceremony — three lines plus a `use tracing::Instrument` import,
nobody writes those when adding a new seam in a hurry. The result:
async cognition stages stayed untimed even though `time_sync!`
makes sync-block timing one line.
`time_probe!` collapses async timing to the same one-line shape:
```rust
// Before — every async timing site:
use tracing::Instrument;
let span = tracing::info_span!("time", name = "analyze",
probe_class = "timing");
let analysis = analyze(input).instrument(span).await;
// After:
let analysis = time_probe!("analyze", analyze(input));
```
## Why this didn't ship in PR #1529
The existing comment block in `routing/macros.rs` documents why an
`async`-timing macro was deliberately deferred:
1. Naming collision with `crate::logging::time_async!` (RAII
TimingGuard shape — different observability path).
2. The previous `time!` macro was a foot-gun: it expanded to
`let _enter = span.enter(); $body` where `$body` contained
`.await`, holding `_enter` across the await suspension and
breaking `URI_STACK` per the d1cf19d dispatch fix.
This commit addresses both:
- **Naming**: `time_probe!` (not `time_async!`) — the suffix names
the OUTPUT (a timing probe), not the executor shape. Keeps the
`crate::logging::time_async!` namespace untouched; the two macros
stay disjoint.
- **Safety by construction**: the macro expands to
`$future.instrument(span).await`. The future itself enters /
exits the span via `Future::poll` boundaries — no scope guard
ever held across an await. Same shape `CommandExecutor::dispatch`
uses.
The comment block in macros.rs is replaced with the new macro's
docstring, which preserves the safety reasoning + names the prior
foot-gun for future-developer context.
## Tests (+2)
`routing::macros::tests`:
- `time_probe_returns_inner_future_value` — pin that the macro is
VALUE-TRANSPARENT. `time_probe!("seam", expr)` and `expr.await`
must produce the same value at the call site, so adding the
probe is a pure observability addition with no shape change.
Uses a `current_thread` tokio runtime so the test stays
executor-light.
- `time_probe_nested_compose_and_return_inner_value` — pin that
multiple `time_probe!` calls compose. The inner span becomes a
child of the outer span (same as `time_sync!` nesting); the
value flows through both layers unchanged.
The existing `time_sync!` tests stay unchanged — sync timing is
unaffected by this addition.
## Manual updated
`docs/architecture/RTOS-DEBUGGER-PROBES.md` — the macro table at
the top now lists `time_probe!` alongside `probe!` / `time_sync!`
/ `time_async!` / `stack!` with a brief "prefer this over bare
`.instrument(...)` ceremony" note + a contrast with the
RAII-shape `time_async!` from `crate::logging`. Operators
filter sync + async timings together via
`CONTINUUM_PROBE_CLASSES=timing` and see one flat timeline.
## Why this lands here (not a separate PR)
Per Joel's `[[refine-tools-as-you-use-them]]`: refine the
substrate AS I use it, not after. I'm shipping cognition fixes
that need timing seams across async boundaries (#149 prefill
caching, #112-114 inference-handle bypass, future analyze
optimizations). Without `time_probe!` the next time I'd
sprinkle async timing I'd skip it because the ceremony is
prohibitive. Better: refine the substrate, ship the cognition
work + the substrate refinement that makes it sustainable.
Parent task: substrate refinement under `[[refine-tools-as-you-use-them]]`
Companion PRs in flight: #1538 (boot wiring) + #1539 (silence + identity)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(probes): time_probe! revision per reviewer mandate findings
Three adversarial reviewers spawned per the new reviewer-mandate
doctrine (`[[reviewer-mandate-elegance-and-substrate-viability]]`)
BLOCKED with substantive findings. This commit addresses the
in-scope ones; the deeper substrate gaps are tracked as follow-ups.
## In-scope fixes (this commit)
1. **Field rename `name` → `seam`.** Reviewer 3 flagged collision
risk — other probes use `name` for different semantics. `seam`
is unambiguous and tells operators to write
`jq 'select(.fields.seam == "cognition.analyze")'`.
2. **Hidden `use ::tracing::Instrument as _;` removed.** Reviewer 1
flagged the scoped import inside macro body as unconventional
and cognitively load-bearing. Replaced with fully-qualified
`::tracing::Instrument::instrument(future, span).await` call —
no hidden import, contract visible at the call site.
3. **Docstring honesty.** Reviewer 2 flagged the prior "zero cost
when disabled" claim as overclaim — `Instrumented<F>` wrapper
persists at runtime even with `release_max_level_*` features.
New cost section: ~24 bytes per call site, one branch per
poll, allocates `Span` regardless of subscriber state.
Acceptable for cognition seams (Qwen dominates wall-clock);
bench per task #198 before sprinkling into hot loops.
4. **Error-path test.** Reviewer 3 flagged missing Result-future
coverage. New `time_probe_propagates_error_from_inner_future`
pins that `Err` flows through unchanged per
`[[no-fallbacks-ever]]`.
5. **Manual example block.** Reviewer 3 flagged the "How to add a
probe" section showing only `time_async!` (the RAII shape) but
not `time_probe!`. Now shows both with explicit guidance:
substrate seams use `time_probe!`; legacy logging-crate seams
use `time_async!`. Includes the persistence caveat (see #196).
## Follow-up substrate gaps (separate tasks)
- **#196**: `ProbeRouterLayer` + `JsonlProbeFileSink` only
implement `on_event`, not `on_close`. `time_sync!` AND
`time_probe!` emit SPANS, not events — neither timing macro
actually persists timings to the JSONL log today. The call
shape ships here; the routing side ships in #196. The macro
docstring + manual carry the caveat explicitly.
- **#197**: Probe class taxonomy decision — flat `timing` vs
hierarchical. Operators filtering `cognition` won't catch
cognition timings under the flat scheme; substrate convention
needs to be picked.
- **#198**: Probe Layer allocation hot-path audit — reviewer 2
estimated ~50-100 HashMap allocs/sec per persona; benchmark
before sprinkling into every async seam.
## Why this lands as a revision rather than withdrawal
Per `[[refine-tools-as-you-use-them]]`: ship the call-site shape
that becomes stable. The routing-side gap (#196) is its own slice
worth doing right rather than rushing into this PR. The docstring
+ manual carry the caveat so no one mistakes the macro for an
end-to-end shipping observability primitive — yet.
## Tests
3 passing:
- `time_probe_returns_inner_future_value`
- `time_probe_propagates_error_from_inner_future` (new — pins
Result futures don't swallow errors)
- `time_probe_nested_compose_and_return_inner_value`
## Doctrine
- `[[reviewer-mandate-elegance-and-substrate-viability]]` — three
adversarial lenses (architecture / speed-viability / probe-
coverage) all surfaced real findings. The mandate works.
- `[[refine-tools-as-you-use-them]]` — revising a primitive in
response to reviewer feedback IS the application work informing
the substrate.
- `[[no-fallbacks-ever]]` — error-path test pinned; substrate
refuses silent swallowing at any seam.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
joelteply
added a commit
that referenced
this pull request
Jun 6, 2026
…ow persist (#196) (#1541) * feat(probes): time_probe! macro — safe one-line async timing for cognition seams Per Joel 2026-06-06 `[[refine-tools-as-you-use-them]]`: I hit this friction in the silence-affordance + identity-grounding work and sat on it. Every async timing site in the cognition path was an `.instrument(info_span!("time", name=..., probe_class="timing")).await` ceremony — three lines plus a `use tracing::Instrument` import, nobody writes those when adding a new seam in a hurry. The result: async cognition stages stayed untimed even though `time_sync!` makes sync-block timing one line. `time_probe!` collapses async timing to the same one-line shape: ```rust // Before — every async timing site: use tracing::Instrument; let span = tracing::info_span!("time", name = "analyze", probe_class = "timing"); let analysis = analyze(input).instrument(span).await; // After: let analysis = time_probe!("analyze", analyze(input)); ``` ## Why this didn't ship in PR #1529 The existing comment block in `routing/macros.rs` documents why an `async`-timing macro was deliberately deferred: 1. Naming collision with `crate::logging::time_async!` (RAII TimingGuard shape — different observability path). 2. The previous `time!` macro was a foot-gun: it expanded to `let _enter = span.enter(); $body` where `$body` contained `.await`, holding `_enter` across the await suspension and breaking `URI_STACK` per the d1cf19d dispatch fix. This commit addresses both: - **Naming**: `time_probe!` (not `time_async!`) — the suffix names the OUTPUT (a timing probe), not the executor shape. Keeps the `crate::logging::time_async!` namespace untouched; the two macros stay disjoint. - **Safety by construction**: the macro expands to `$future.instrument(span).await`. The future itself enters / exits the span via `Future::poll` boundaries — no scope guard ever held across an await. Same shape `CommandExecutor::dispatch` uses. The comment block in macros.rs is replaced with the new macro's docstring, which preserves the safety reasoning + names the prior foot-gun for future-developer context. ## Tests (+2) `routing::macros::tests`: - `time_probe_returns_inner_future_value` — pin that the macro is VALUE-TRANSPARENT. `time_probe!("seam", expr)` and `expr.await` must produce the same value at the call site, so adding the probe is a pure observability addition with no shape change. Uses a `current_thread` tokio runtime so the test stays executor-light. - `time_probe_nested_compose_and_return_inner_value` — pin that multiple `time_probe!` calls compose. The inner span becomes a child of the outer span (same as `time_sync!` nesting); the value flows through both layers unchanged. The existing `time_sync!` tests stay unchanged — sync timing is unaffected by this addition. ## Manual updated `docs/architecture/RTOS-DEBUGGER-PROBES.md` — the macro table at the top now lists `time_probe!` alongside `probe!` / `time_sync!` / `time_async!` / `stack!` with a brief "prefer this over bare `.instrument(...)` ceremony" note + a contrast with the RAII-shape `time_async!` from `crate::logging`. Operators filter sync + async timings together via `CONTINUUM_PROBE_CLASSES=timing` and see one flat timeline. ## Why this lands here (not a separate PR) Per Joel's `[[refine-tools-as-you-use-them]]`: refine the substrate AS I use it, not after. I'm shipping cognition fixes that need timing seams across async boundaries (#149 prefill caching, #112-114 inference-handle bypass, future analyze optimizations). Without `time_probe!` the next time I'd sprinkle async timing I'd skip it because the ceremony is prohibitive. Better: refine the substrate, ship the cognition work + the substrate refinement that makes it sustainable. Parent task: substrate refinement under `[[refine-tools-as-you-use-them]]` Companion PRs in flight: #1538 (boot wiring) + #1539 (silence + identity) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(probes): time_probe! revision per reviewer mandate findings Three adversarial reviewers spawned per the new reviewer-mandate doctrine (`[[reviewer-mandate-elegance-and-substrate-viability]]`) BLOCKED with substantive findings. This commit addresses the in-scope ones; the deeper substrate gaps are tracked as follow-ups. ## In-scope fixes (this commit) 1. **Field rename `name` → `seam`.** Reviewer 3 flagged collision risk — other probes use `name` for different semantics. `seam` is unambiguous and tells operators to write `jq 'select(.fields.seam == "cognition.analyze")'`. 2. **Hidden `use ::tracing::Instrument as _;` removed.** Reviewer 1 flagged the scoped import inside macro body as unconventional and cognitively load-bearing. Replaced with fully-qualified `::tracing::Instrument::instrument(future, span).await` call — no hidden import, contract visible at the call site. 3. **Docstring honesty.** Reviewer 2 flagged the prior "zero cost when disabled" claim as overclaim — `Instrumented<F>` wrapper persists at runtime even with `release_max_level_*` features. New cost section: ~24 bytes per call site, one branch per poll, allocates `Span` regardless of subscriber state. Acceptable for cognition seams (Qwen dominates wall-clock); bench per task #198 before sprinkling into hot loops. 4. **Error-path test.** Reviewer 3 flagged missing Result-future coverage. New `time_probe_propagates_error_from_inner_future` pins that `Err` flows through unchanged per `[[no-fallbacks-ever]]`. 5. **Manual example block.** Reviewer 3 flagged the "How to add a probe" section showing only `time_async!` (the RAII shape) but not `time_probe!`. Now shows both with explicit guidance: substrate seams use `time_probe!`; legacy logging-crate seams use `time_async!`. Includes the persistence caveat (see #196). ## Follow-up substrate gaps (separate tasks) - **#196**: `ProbeRouterLayer` + `JsonlProbeFileSink` only implement `on_event`, not `on_close`. `time_sync!` AND `time_probe!` emit SPANS, not events — neither timing macro actually persists timings to the JSONL log today. The call shape ships here; the routing side ships in #196. The macro docstring + manual carry the caveat explicitly. - **#197**: Probe class taxonomy decision — flat `timing` vs hierarchical. Operators filtering `cognition` won't catch cognition timings under the flat scheme; substrate convention needs to be picked. - **#198**: Probe Layer allocation hot-path audit — reviewer 2 estimated ~50-100 HashMap allocs/sec per persona; benchmark before sprinkling into every async seam. ## Why this lands as a revision rather than withdrawal Per `[[refine-tools-as-you-use-them]]`: ship the call-site shape that becomes stable. The routing-side gap (#196) is its own slice worth doing right rather than rushing into this PR. The docstring + manual carry the caveat so no one mistakes the macro for an end-to-end shipping observability primitive — yet. ## Tests 3 passing: - `time_probe_returns_inner_future_value` - `time_probe_propagates_error_from_inner_future` (new — pins Result futures don't swallow errors) - `time_probe_nested_compose_and_return_inner_value` ## Doctrine - `[[reviewer-mandate-elegance-and-substrate-viability]]` — three adversarial lenses (architecture / speed-viability / probe- coverage) all surfaced real findings. The mandate works. - `[[refine-tools-as-you-use-them]]` — revising a primitive in response to reviewer feedback IS the application work informing the substrate. - `[[no-fallbacks-ever]]` — error-path test pinned; substrate refuses silent swallowing at any seam. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(probes): on_close in both probe Layers — time_sync!/time_probe! now persist (#196) Load-bearing fix discovered by reviewer-mandate review of #1540 (time_probe!): both `ProbeRouterLayer` and `JsonlProbeFileSink` only implemented `on_event`, so the timing spans emitted by `time_sync!` and `time_probe!` were observed by no consumer. Operators running `CONTINUUM_PROBE_CLASSES=timing` saw zero timing records on disk no matter how many seams were instrumented. The macros were theatrical — Joel's RTOS-debugger framing required actual wall-clock persistence to "hunt down bottlenecks." This commit closes the gap: - `ProbeRouterLayer`: add `SpanProbeMeta` + `on_new_span` + `on_close` so each `probe_class`-carrying span fans out a `ProbeEvent { class, duration_ms, .. }` on close. Spans without `probe_class` are ignored at zero allocation cost per `[[no-fallbacks-ever]]`. - `JsonlProbeFileSink`: mirror the same shape — `FileSinkSpanMeta` + `on_new_span` + `on_close`. Same class filter applies; `duration_ms` is injected into the on-disk JSON `fields` so the line shape matches the broadcast envelope. - `time_sync!`: unify field name to `seam = $name` (was `name`) so it matches `time_probe!`. Operators get one `jq` query — `.fields.seam == "phase"` — that works for either macro. The pre-existing value-transparency tests don't assert on field names so this rename is non-breaking. Tests: - `probe_router::tests::time_sync_span_close_fans_out_timing_event` - `probe_router::tests::time_probe_span_close_fans_out_timing_event` - `probe_router::tests::span_without_probe_class_does_not_fanout` - `probe_file_sink::tests::time_sync_span_close_persists_timing_to_jsonl` - `probe_file_sink::tests::time_probe_span_close_persists_timing_to_jsonl` - `probe_file_sink::tests::plain_span_close_does_not_persist_to_jsonl` - `probe_file_sink::tests::class_filter_applies_to_timing_spans` 244/244 routing tests pass; 13/13 macro tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(probes): hoist SpanProbeMeta to shared module — addresses R1+R2 BLOCKs Reviewer-mandate review of #1541's first commit BLOCKED twice with overlapping load-bearing concerns: - R1 (architecture/design): SpanProbeMeta + FileSinkSpanMeta were byte-for-byte identical with ~60 lines of copy-pasted lock/visit logic. Each Layer captured its own Instant::now() at on_new_span -> router and sink reported subtly different duration_ms for the same span. No test verified both layers compose in one subscriber. - R2 (speed/Intel-Mac viability): on_new_span fired for EVERY tracing span the substrate emits (tokio executor, framework, plain info_span!). Each Layer's visitor allocated a HashMap + walked ALL fields with format!(...) before discarding when probe_class was missing. Per-span allocator pressure on the LCD floor. This refactor hoists the lifecycle into routing/probe_span_meta.rs: 1. span_carries_probe_class(attrs) - cheap static check. Walks attrs.metadata().fields() (static field set, no allocation) for the probe_class name. The vast majority of spans short-circuit here with zero visitor work. Addresses R2's per-span hot-path cost. 2. ensure_probe_meta(attrs, span_ref) - idempotent install. First Layer to see the span populates the extension; second Layer finds it already present and no-ops. Both Layers visit the attrs ONCE total, not once per Layer. Addresses R2's doubled-cost concern. 3. build_timing_event_from_meta(span_ref, uri_chain) - shared event builder. Both Layers read the SAME start: Instant from the extension -> identical duration_ms on broadcast stream and JSONL log. Addresses R1's timing-drift concern. 4. New composition test: probe_file_sink::tests::both_layers_in_one_subscriber_agree_on_duration_ms installs ProbeRouterLayer + JsonlProbeFileSink in one subscriber, fires a time_sync!, asserts the broadcast subscriber + JSONL line agree on class + seam + duration_ms. Pins R1's "no composition test" gap. 5. docs/architecture/RTOS-DEBUGGER-PROBES.md pins the seam-not-name field-naming convention per R1's minor - operators can jq '.fields.seam' against both time_sync! and time_probe! output without thinking about which macro emitted the record. Tests: 247/247 routing tests pass (3 net new). The composition test would have caught the original duplication-induced drift had it existed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Slice P — the substrate's universal grid addressing protocol. Every load-bearing operation gains a typed URI; every URI passes through one auth gate; every cross-grid call rides one typed transport. Per docs/architecture/GRID-ADDRESSING-AND-ROUTING.md (the canonical Slice P design — grew from ~1k to ~1.6k lines on this branch, every typed primitive has a section).
After the initial Slice P landed (commands), the event-side parallel work shipped on this same branch — making the substrate's cross-grid round-trip whole for both temporal shapes (one-shot commands AND ongoing event subscriptions). Per
[[events-are-the-organic-rtos-substrate]]: events ride the sameAirc::request/Airc::replyprimitives commands ride; same routing chain, same auth gate, same observability.fa25de80-0c1b-4de5-8ff9-524d95e303cdfa25de80/slice-p-grid-addressing→mainTyped primitives shipped — command surface
86b56552eCommandUrityped enum + RFC 3986 parser139fbd970Commands.execute()acceptsimpl Into<CommandUri>(migration shim)4c188285dprobe!+time!macrosd4df98d5eVerdict+EnvironmentIdtyped primitives052f2ba41EnvSelector → EnvironmentIdconsolidation (-5 net lines)0dcfa3ea1stack!macro — substrate's call-stack as URI scopesc5d857e8dUriCaptureLayer— multi-frame ancestry via tracingd1cf19dc5.instrument(span)— async-correct URI propagatione3f7cdc3bProbeRouterLayer— per-class event fanoutca6170444debug/probes/{open,next,close}ServiceModule350c1069eRouteDecision+route()— typed transport selectorad63adad2AuthPolicygate — Verdict between route and dispatchc0a79524fTransporttrait +NotImplementedRemoteTransport951777621AircCommandProtocol— typed wire shape (command)c9dc2efcfAircTransport— Transport impl over airc-lib request/reply66e7c3cd2CommandRequestHandler+execute_with_callerthreadingPlus 10 design-doc commits evolving the canonical doc alongside the code.
Typed primitives shipped — event surface (this branch's second arc)
67199b2c1AircEventProtocol— typed wire shape (subscribe / deliver / unsubscribe + ack)a6a534c52AircEventTransport— caller-side cross-grid subscribe viaAirc::request+ filtered EventStream demuxresolve_subscribe/decode_ack/decode_deliver/matches_subscription803ab1760EventPublisherState— peer-side subscription registry + per-subscription monotonic sequence + filter predicateArc<state>between the two ConsumerAdaptersfba968283EventSubscribeAdapter+EventUnsubscribeAdapter— ConsumerAdapter wrappers around the state machineCommandRequestHandler49053c240AircEventPublisherfacade +publish(topic, payload)fan-out +build_publish_envelopespure functionAfter this set of commits, the event-side cross-grid round-trip is complete: subscribe → ack → repeated Deliver → unsubscribe → ack, every envelope typed end-to-end.
What's covered, what's not
matches_subscription+decode_deliver_frameunit tests.build_deliver_frame_passes_caller_side_matches_subscription— the frame the peer-side builds satisfies the caller-side predicate. A future refactor breaking either side fails loudly.publish()/on_envelope()/subscribe()calls themselves are covered by the LAN-loopback integration test (feat: Persona Cognition System (Phase 1) - Autonomous Loop, Cognition Framework, Genome Infrastructure #188) onceTwoAircLoopbackfixture (Test issue for UUID bug investigation #187) lands. This is the same gap the command-side has and the same plan to close it.Transporttrait extension for event subscription URI routing — not in this PR; lives in a follow-up slice (the trait already shipped for commands).Events::subscribe::<E>(uri)typed consumer API (row above wire-level) — follow-up slice, needs localEvents::emitinfrastructure first.Doctrine the code enacts
[[no-fallbacks-ever]]— every error path is typed; no silent degradation. Forbidden / Deferred / TransportUnreachable each carry actionable reason. Empty subscription topic refused upfront with doctrine cite. Per-send fanout failure surfaces the partial count.[[commands-are-kernel-level-and-compose]]— URI dispatch + Transport seam compose; non-Local variants slot into one trait method. Events ride the same kernel.[[airc-headers-are-the-routing-layer]]—HEADER_COMMAND_*+HEADER_EVENT_*+HEADER_CONTINUUM_BODY_HINTride airc; middleware filters without body parsing.[[host-the-seemingly-impossible]]— 14 personas × N substructures × M envs × P nodes all addressable with one URI grammar.[[observability-is-half-the-architecture]]—probe!per-class + tracing-span URI propagation + ProbeRouterLayer.[[substrate-gate-vs-persona-cognition]](saved this slice) — gate decides access, NEVER response.[[citizens-have-envs-not-the-other-way-around]](saved this slice) — one keypair per citizen; envs are embodiments.[[events-are-the-organic-rtos-substrate]](saved this slice) — events are the coordination primitive CBAR is built on. Commands and events are two temporal shapes of the same primitive.[[substrate-complete-then-node-reintroduced-as-shell]](saved this slice) — sequencing: substrate → docs sweep → Node returns as thin shell.[[addressable-cognition-makes-triggers-trivial]](saved this slice) — load-bearing decisions emit typed events; triggers = subscribe + filter + dispatch.The dispatcher reads like the doctrine
The event surface composes the same way:
Airc::requestfor subscribe / unsubscribe with a typed body and the auth gate at the inbound boundary;Airc::publishfor Deliver frames with per-subscription demux headers;EventPublisherStateholds the per-(sub, topic, filter, sequence) state behind one Arc.Each line is a typed primitive that lands as one commit. The compiler verifies the composition.
What's NOT in this PR (deferred to follow-up slices)
Aircinstances in process. Needs theTwoAircLoopbacktest fixture (Test issue for UUID bug investigation #187) which builds the missing public airc-lib test primitive.Transporttrait extension for event subscribe URIs — when the trait grows asubscribe()method soairc://<peer>/events/<topic>/subscribeURIs route through the same chain as commands.Events::subscribe::<E>(uri)consumer-level typed API — the row above wire-level. Needs localEvents::emitinfrastructure first; not yet in continuum-core.env=websubscriber per[[substrate-complete-then-node-reintroduced-as-shell]].These are tracked as separate cards/slices so this PR is reviewable as a unit of typed substrate work.
Test plan
cargo test -p continuum-core --lib --features metal,accelerate -- routing command_executor)cargo test -p continuum-core --lib --features metal,accelerate probe_stream)TwoAircLoopbackfixture (Test issue for UUID bug investigation #187) lands