docs: step-3 rewrites — OQ-39/40 resolved, C-08 closed, channel-operations.md put/control/resources contracts, review 010/011 closed with the S-01 sketch corrected
- open-questions.md: OQ-39 → ADR-052, OQ-40 → ADR-053 (resolutions replace the deferred-until-needed posture) - channel-operations.md: the put contract (initiator allocates + streams, reserved input key, one response, acks-in-result sentence), the control routing (ADR-052), the enumerator aggregation (ADR-053), the connection-owner paragraph's pub-era correction, the ADR table rows - ADR-051 §6: PubTypedOpen → the put translation; ADR-047's 2026-09-06 amendment records its era honestly (the dynamic half is now ADR-053) - review 010: all four findings resolved (with the S-01 sketch correction so the published record stops carrying the wrong-graph reading) - review 011: closed (all seven pins implemented + gates landed) - review 001 C-08: resolved note (odd/even + adopt, implemented) - AGENTS.md: the 'Stub Guard' section — the not_implemented grep is part of cargo test; the registration invariants and the implement-or-stop ruling are release-blocking Verified: cargo test (696+guard), clippy -D warnings, fmt, doc clean.
This commit is contained in:
1 parent
944c300c17
commit
3317ea0963
10 files changed
+205
-43
No files matched your search
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-08-12
|
||||
status: current
|
||||
last_updated: 2026-10-13
|
||||
---
|
||||
|
||||
# channel-operations.md — Channel Lifecycle on the Call Protocol
|
||||
@@ -60,15 +60,18 @@ Response (`call.responded`):
|
||||
|-------|------|---------|
|
||||
| `channel_id` | u32 | Allocated by the connection owner (ADR-047 §5). In the `Sub` case, the responder (producer). Both sides route chunks with this ID to the new channel. |
|
||||
|
||||
**`channel_id` allocation (ADR-047 §5): connection-owner allocates.**
|
||||
**`channel_id` allocation (ADR-047 §5, as amended for the put path by
|
||||
ADR-047 amendment 4): connection-owner allocates.**
|
||||
The side that holds the `ChannelManager` for that connection allocates
|
||||
the `channel_id` via a monotonic `AtomicU32` (`next_id.fetch_add(1,
|
||||
Relaxed)`) and returns it in the response. In the `Sub` case the
|
||||
responder owns the connection and allocates; in the `Pub` case the
|
||||
initiator owns and allocates. One round-trip before data flows — the
|
||||
same round-trip the call protocol makes for every operation. All
|
||||
current channel types (TTY, tunnel, SSH) already require a negotiation
|
||||
round-trip, so the open round-trip is not additive latency.
|
||||
the `channel_id` via a monotonic `AtomicU32` (odd/even split — connect
|
||||
side starts at 1, accept at 2) and returns it in the response. In the
|
||||
`Sub` case the responder owns the connection and allocates — one
|
||||
round-trip before data flows (the same round-trip every call makes).
|
||||
In the `Pub` case (the put contract above) the initiator owns,
|
||||
allocates, and streams bytes **before** the open resolves — the
|
||||
early-arrival parking covers the adopt race. All current channel
|
||||
types (TTY, tunnel, SSH) already require a negotiation round-trip, so
|
||||
the open round-trip is not additive latency.
|
||||
|
||||
**Error codes** (`CallError.code` strings):
|
||||
|
||||
@@ -110,19 +113,80 @@ the existing teardown machinery, unchanged. Head-of-line safety: the
|
||||
serving loop spawns Once invocations as independent tasks, so a slow
|
||||
establisher on one open op does not block other calls on channel 0.
|
||||
|
||||
### `channels/<alpn>/pub` — publish a binary stream
|
||||
### `channels/<alpn>/pub` — publish a binary stream (the put contract, ADR-047 amendment 4)
|
||||
|
||||
`OperationType::Pub` (ADR-046). The initiator publishes a stream of
|
||||
`call.published` events to the responder; the responder's `SinkHandler`
|
||||
consumes them and returns a single `call.responded`. The
|
||||
`channel_open` marker on the op spec tells the channels layer the
|
||||
stream is binary — the `call.published` chunks carry binary payloads
|
||||
on a data channel (not JSON on channel 0).
|
||||
A marked `Pub`-typed open op is the **put** shape: the data plane is
|
||||
the adopted channel's `BiStream` — never the call plane. The contract,
|
||||
pinned end-to-end (ADR-047 amendment 4; review 011's corrections are
|
||||
the authoritative shape — a serving-loop `PublishStream` sketch was
|
||||
considered and rejected, it is ADR-046's *unmarked* pub semantics):
|
||||
|
||||
1. **The initiator allocates and writes immediately.** The putter
|
||||
allocates the channel id on its own side (connect side → odd, per
|
||||
the ADR-047 §5 odd/even split), inserts the **reserved input key
|
||||
`"channel_id"`** into the open op's input, and streams bytes on the
|
||||
channel before the open resolves — the early-arrival parking
|
||||
(ADR-051's tested pre-adopt buffer) covers the responder-adopts
|
||||
race. Zero `call.published` on marked ops: chunks on the call plane
|
||||
are rejected loudly with `channel:publish_chunks_rejected` (the
|
||||
first chunk writes the rejection in the initiator's direction; the
|
||||
handler is never fed). An unmarked `Pub` op is an ordinary ADR-046
|
||||
sink (JSON chunks, `publish_schema`); it does not ride the channel
|
||||
machinery.
|
||||
2. **The responder adopts.** `ChannelCore::register_pub_openable` is
|
||||
the only registration seam: the wrapper strips the reserved key
|
||||
pre-validation (dispatch layer, before `input_schema`) and adopts
|
||||
the given id (`adopt_channel` — the remote parity range enforced;
|
||||
failure replies `channel:open_failed` with reason `adopt_failed`).
|
||||
3. **The wrapper is the responder of record:** `check_open` → adopt →
|
||||
bounded establisher (offer validation + ack fields +
|
||||
`Establishment::with_control`, ADR-049 amendment 3's mechanism) →
|
||||
the `PubOpenHandler` runs on the adopted channel's `Connection`,
|
||||
**awaited inline** → the standard completion teardown → **one
|
||||
response**, merge order pinned:
|
||||
|
||||
```
|
||||
{ "channel_id": <adopted> } → ack fields → handler result
|
||||
```
|
||||
|
||||
(the handler result wins on key collision — its computed result
|
||||
postdates the ack; a handler `Err` IS the response, and the channel
|
||||
tears down).
|
||||
4. **The ack-timing sentence (protocol semantics):** a sink op gets
|
||||
exactly one `call.responded`, produced after the handler completes
|
||||
— a putter stream-waits nothing; the establishment ack is
|
||||
informative-in-result, never a gating ack. The fetch/put asymmetry
|
||||
is direction-inherent: fetch (`Sub`) acks at establishment (the
|
||||
`{channel_id}` response precedes any byte); put (`Pub`) acks in the
|
||||
result. A putter that waits for the ack deadlocks — this sentence
|
||||
is the contract, not per-consumer folklore.
|
||||
5. **The putter-facing client shape:**
|
||||
`ChannelClient::open_pub_channel(operation_id, input, alpn)`
|
||||
allocates the odd id, sends the open op in the background, and
|
||||
returns `(id, send, recv, reply)` immediately; `reply.resolve()`
|
||||
is the awaited merged response.
|
||||
|
||||
**Error codes** (the pub path):
|
||||
|
||||
| code | meaning | retryable |
|
||||
|------|---------|-----------|
|
||||
| `channel:open_failed` | establishment failure (ADR-049 §3 vocabulary) or adoption failure — new reason `adopt_failed` (collisions, cap, out-of-range reserved ids; ADR-047 amendment 4 §4.4) | false |
|
||||
| `channel:publish_chunks_rejected` | `call.published` chunks on a marked op (the data plane is the channel, ADR-047 amendment 4 §4.1) | false |
|
||||
| `INVALID_INPUT` | absent/non-u32 reserved `channel_id` key on a marked pub op (ADR-047 amendment 4 §4.3) | false |
|
||||
|
||||
The hub-as-proxy pattern (ADR-042, ADR-047 §1) composes on top: a
|
||||
worker publishes to the hub, the hub owns the resource, and consumers
|
||||
subscribe from the hub. The broker (topic registry, `Pub`↔`Sub`
|
||||
matching) is a hub concern, not an alkcall concern (ADR-047 §1, Gap B).
|
||||
worker publishes to the hub, the hub owns the resource, consumers
|
||||
subscribe from the hub. Marked pub openables relay through the put
|
||||
translation (ADR-047 amendment 4 §4.7): the hub allocates its own
|
||||
connect-side id, opens the spoke op in the background (put
|
||||
acks-in-result — an establisher awaiting the spoke's response would
|
||||
deadlock the leg), pumps consumer bytes → spoke leg, and mirrors the
|
||||
spoke's single response with its reserved key stripped.
|
||||
|
||||
Registration invariants (ADR-047 amendment 4 §4.8 — the stub-class
|
||||
guard): a marked spec refuses the generic registration path and
|
||||
refuses `publish_schema`; `register_openable*` refuse `Pub`-typed
|
||||
specs; `register_pub_openable` refuses unmarked/non-`Pub` specs.
|
||||
|
||||
## The generic ops (keyed by `channel_id`)
|
||||
|
||||
@@ -159,7 +223,7 @@ TTY's 5-byte payload format); for tunnels it is the last data byte
|
||||
before close. This invariant crosses two channels (the data channel and
|
||||
channel 0), so the channels layer owns the ordering guarantee.
|
||||
|
||||
### `channel/control` — out-of-band control on channel 0
|
||||
### `channel/control` — out-of-band control on channel 0 (implemented, ADR-052)
|
||||
|
||||
For control that doesn't need ordering relative to data (resize, signal,
|
||||
keepalive):
|
||||
@@ -174,11 +238,23 @@ keepalive):
|
||||
}
|
||||
```
|
||||
|
||||
The channels layer routes `message` to the handler's control handle for
|
||||
`channel_id`. The `message` JSON is ALPN-specific; the channels layer
|
||||
does not interpret it.
|
||||
The channels layer routes `message` to the channel's control callback
|
||||
(ADR-052): the ALPN crate contributes it through its open-op
|
||||
establisher (`Establishment::with_control`); the wrapper installs it
|
||||
under the allocated/adopted id and every teardown path removes it. The
|
||||
`message` JSON is ALPN-specific; the channels layer does not interpret
|
||||
it. The reply is `{ "control": <callback output> }`, or:
|
||||
|
||||
### `channel/resources/subscribe` — live resource discovery
|
||||
| code | meaning |
|
||||
|------|---------|
|
||||
| `channel:unknown_channel` | no such channel (pre-existing class) |
|
||||
| `channel:no_control_handle` | the channel's ALPN registered no control surface — a capability answer, not a failure (ADR-052 §3); data-ordered control rides the data channel (DP-4 below) |
|
||||
| (callback's own code) | the callback's `CallError` verbatim |
|
||||
|
||||
Control that MUST be ordered relative to data rides the data channel's
|
||||
`BiStream` (the control-message division below is unchanged).
|
||||
|
||||
### `channel/resources/subscribe` — live resource discovery (implemented, ADR-053)
|
||||
|
||||
**This is a `Sub` operation (ADR-021), not a polled Query.** The call
|
||||
protocol has `StreamingHandler` / `invoke_streaming` (implemented and
|
||||
@@ -193,8 +269,21 @@ start/stop.
|
||||
}
|
||||
```
|
||||
|
||||
The responder registers a `StreamingHandler` that emits a
|
||||
`ResponseEnvelope` whenever the resource set changes. Each event:
|
||||
Each openable ALPN crate provides a **resource enumerator**
|
||||
(ADR-053 §1 — snapshot-then-changes: the first item is the current
|
||||
payload, subsequent items are full replacements; the producing crate
|
||||
owns its change detection) at assembly/install time:
|
||||
`ResourceEnumerators::register(alpn, enumerator)`, one shared registry
|
||||
per accepting peer. The handler aggregates the enumerators registered
|
||||
at subscription start (the **scan-once** pin — a later registration is
|
||||
invisible to an in-flight subscription; re-subscribe):
|
||||
|
||||
- **non-empty registry:** each enumerator item becomes one event,
|
||||
`{ "output": { "resources": [ { "alpn": …, …payload } ] } }` — the
|
||||
first events across the ALPNs are the initial snapshot;
|
||||
- **empty registry:** exactly one `{ "resources": [] }` event, then
|
||||
the stream stays open (subscribe, read the first event, cancel —
|
||||
the poll use case).
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -471,6 +560,8 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
||||
| [021](decisions/021-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses |
|
||||
| [046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | Pub Operation Type | The `Pub`/`Sub` primitives the per-ALPN open ops build on |
|
||||
| [049](decisions/049-channel-open-establishment-phase.md) | Channel-Open Establishment Phase | The establisher hook; typed `channel:open_failed`; `Establishment.plan` (amendment 2); the `JoinHandle` lifetime contract |
|
||||
| [052](decisions/052-channel-control-surface.md) | `channel/control` Control-Handle Surface | The establisher-contributed `ChannelControl`; wrapper-install/teardown-removal; `channel:no_control_handle` |
|
||||
| [053](decisions/053-resource-enumerators.md) | Live Resource Enumerators | The snapshot-then-changes enumerator contract; scan-once aggregation; the empty seed |
|
||||
| [050](decisions/050-pump-bidi-two-pump-helper.md) | `pump_bidi` Two-Pump Helper | The data-plane helper openable-ALPN handlers await inline |
|
||||
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens (and why the cap is per direct-caller, not per `forwarded_for`) |
|
||||
| [011](decisions/011-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The parallel — a channel slot is a resource, the cap is a quota check; `resource_id_path` works again under per-ALPN ops |
|
||||
|
||||
Reference in new issue
Block a user