Skip to content

feat(routing): peer-side CommandRequestHandler + execute_with_caller threading#1529

Merged
joelteply merged 33 commits into
canaryfrom
fa25de80/slice-p-grid-addressing
Jun 6, 2026
Merged

feat(routing): peer-side CommandRequestHandler + execute_with_caller threading#1529
joelteply merged 33 commits into
canaryfrom
fa25de80/slice-p-grid-addressing

Conversation

@joelteply
Copy link
Copy Markdown
Contributor

@joelteply joelteply commented Jun 5, 2026

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 same Airc::request/Airc::reply primitives commands ride; same routing chain, same auth gate, same observability.

  • Card: fa25de80-0c1b-4de5-8ff9-524d95e303cd
  • Branch: fa25de80/slice-p-grid-addressingmain
  • Tests: 197 routing + command_executor unit tests pass (154 command-side + 43 event-side)

Typed primitives shipped — command surface

Commit Primitive Design-doc section
86b56552e CommandUri typed enum + RFC 3986 parser §URI grammar
139fbd970 Commands.execute() accepts impl Into<CommandUri> (migration shim) §URI grammar
4c188285d Dispatch span + probe! + time! macros §Universal logging + true JTAG
d4df98d5e Verdict + EnvironmentId typed primitives §Auth gate / §Environments
052f2ba41 EnvSelector → EnvironmentId consolidation (-5 net lines) §Environments
0dcfa3ea1 stack! macro — substrate's call-stack as URI scopes §Stack ancestry
c5d857e8d UriCaptureLayer — multi-frame ancestry via tracing §Tracing integration
d1cf19dc5 Dispatch .instrument(span) — async-correct URI propagation §Tracing integration
e3f7cdc3b ProbeRouterLayer — per-class event fanout §Per-class routing
ca6170444 debug/probes/{open,next,close} ServiceModule §Per-class routing
350c1069e RouteDecision + route() — typed transport selector §Transport selection
ad63adad2 AuthPolicy gate — Verdict between route and dispatch §Auth gate
c0a79524f Transport trait + NotImplementedRemoteTransport §Transport selection
951777621 AircCommandProtocol — typed wire shape (command) §Cross-grid envelope
c9dc2efcf AircTransport — Transport impl over airc-lib request/reply §Cross-grid envelope
66e7c3cd2 CommandRequestHandler + execute_with_caller threading §Peer inbound

Plus 10 design-doc commits evolving the canonical doc alongside the code.

Typed primitives shipped — event surface (this branch's second arc)

Commit Primitive Notes
67199b2c1 AircEventProtocol — typed wire shape (subscribe / deliver / unsubscribe + ack) 3-message protocol; sequence numbers for drop-detection
a6a534c52 AircEventTransport — caller-side cross-grid subscribe via Airc::request + filtered EventStream demux testable seams: resolve_subscribe / decode_ack / decode_deliver / matches_subscription
803ab1760 EventPublisherState — peer-side subscription registry + per-subscription monotonic sequence + filter predicate shared Arc<state> between the two ConsumerAdapters
fba968283 EventSubscribeAdapter + EventUnsubscribeAdapter — ConsumerAdapter wrappers around the state machine each claims its body_hint; symmetric to CommandRequestHandler
49053c240 AircEventPublisher facade + publish(topic, payload) fan-out + build_publish_envelopes pure function per-send failure surfaces partial-fanout count + failing sub UUID

After 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

  • ✅ Every refusal branch typed + tested via pure functions (testable-seams pattern from reviewer 3's PR feat(routing): peer-side CommandRequestHandler + execute_with_caller threading #1529 feedback applied preemptively to the event-side).
  • ✅ Caller-side filter task (per-subscription mpsc) covered by matches_subscription + decode_deliver_frame unit tests.
  • ✅ Cross-boundary symmetry pinned by 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.
  • ❌ The airc-touching 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) once TwoAircLoopback fixture (Test issue for UUID bug investigation #187) lands. This is the same gap the command-side has and the same plan to close it.
  • Transport trait 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 local Events::emit infrastructure 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_HINT ride 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

let decision = route(command);                       // typed RouteDecision
let verdict  = policy.gate(&decision, caller);       // every URI has a gate
match verdict {
    Forbidden { reason }                  => Err(...),
    Deferred  { reason, prompt_target_env } => Err(...),
    Allowed                                  => {
        match decision {
            RouteDecision::Local { path, .. } => execute_inner(&path, params).await,
            non_local => remote_transport.dispatch(non_local, params).await,
        }
    }
}

The event surface composes the same way: Airc::request for subscribe / unsubscribe with a typed body and the auth gate at the inbound boundary; Airc::publish for Deliver frames with per-subscription demux headers; EventPublisherState holds 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)

These are tracked as separate cards/slices so this PR is reviewable as a unit of typed substrate work.

Test plan

  • Unit tests: 197 routing + command_executor tests pass (cargo test -p continuum-core --lib --features metal,accelerate -- routing command_executor)
  • Probe stream module tests: 10 pass (cargo test -p continuum-core --lib --features metal,accelerate probe_stream)
  • Command surface
    • AircCommandProtocol round-trip: 12 pass
    • AircTransport unit tests: 18 pass (envelope construction, peer-ref mapping, error variants, testable-seams pure functions)
    • CommandRequestHandler unit tests: 14 pass (parse + every malformed shape produces typed error, gate threading)
  • Event surface
    • AircEventProtocol round-trip: 9 pass (subscribe/ack/deliver/unsubscribe envelopes)
    • AircEventTransport: 17 pass (resolve_subscribe + decode_ack + decode_deliver + matches_subscription, every refusal branch)
    • AircEventPublisher: 30 pass (state machine + parse/build pure functions + publish-envelope composition)
    • EventSubscribeAdapter + EventUnsubscribeAdapter: 7 pass (process_subscribe/unsubscribe + shared-state contract)
  • Design doc updated alongside code — every typed primitive has a section
  • LAN-loopback integration test (feat: Persona Cognition System (Phase 1) - Autonomous Loop, Cognition Framework, Genome Infrastructure #188) — deferred to its own slice once TwoAircLoopback fixture (Test issue for UUID bug investigation #187) lands

joelteply and others added 25 commits June 4, 2026 16:44
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>
joelteply and others added 4 commits June 5, 2026 10:31
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>
joelteply and others added 4 commits June 5, 2026 11:22
…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 joelteply merged commit 5d4a430 into canary Jun 6, 2026
3 checks passed
@joelteply joelteply deleted the fa25de80/slice-p-grid-addressing branch June 6, 2026 00:40
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant