docs(adr): amendments pinning the single-publish fix — marked-pub contract (047 am.4), control surface (052), resource enumerators (053), op/register forward-through + marked refusal (022 am.)
Per review 011 §6 sequencing: the four amendment documents drafted before code. All pins, no options — the hedged alternatives are stated as rejected with rationale: - ADR-047 amendment 4: the marked-pub open contract (review 011 §1/§2's seven pins — marker semantics, publish_schema refusal, reserved input key + dispatch strip, wrapper-as-responder-of-record, ack-timing sentence, relay translation, client put-open capability) plus the registration invariants that make stubs unregistrable (§4.8). - ADR-052 (new): channel/control control-handle surface — establisher- contributed ChannelControl, wrapper-owned registry, channel:no_control_handle. - ADR-053 (new): channel/resources/subscribe live resource enumerators — snapshot-then-changes, scan-once aggregation, empty seed. - ADR-022 amendment: announced unmarked Sub/Pub forward through the real forwarding shapes; marked announcements refused (MARKED_SPEC_UNSUPPORTED).
This commit is contained in:
1 parent
4eae18f539
commit
df4d4d3a9e
4 files changed
+617
-2
No files matched your search
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted (amended 2026-06-26, 2026-07-13, and 2026-07-16 — see "Amendments" below; the 2026-07-16 amendment per ADR-045 §5 removes `CallClient::connect`; amendment 2026-09-03 — the bootstrap-op set and the connect-side serving loop, see "Amendment (2026-09-03)" below; amendment 2026-09-04 — the `op/register` collision policy, in that amendment's "Collision policy" paragraph)
|
Accepted (amended 2026-06-26, 2026-07-13, and 2026-07-16 — see "Amendments" below; the 2026-07-16 amendment per ADR-045 §5 removes `CallClient::connect`; amendment 2026-09-03 — the bootstrap-op set and the connect-side serving loop, see "Amendment (2026-09-03)" below; amendment 2026-09-04 — the `op/register` collision policy, in that amendment's "Collision policy" paragraph; amendment 2026-10-13 — announced Sub/Pub forward-through and marked-spec refusal (review 010 S-04), see "Amendment (2026-10-13)" below)
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
@@ -623,3 +623,58 @@ protocol-crate take-over that consumes the dial's `Connection`.
|
|||||||
|
|
||||||
See [client-and-adapters.md](../crates/call/client-and-adapters.md)
|
See [client-and-adapters.md](../crates/call/client-and-adapters.md)
|
||||||
§"CallClient" for the reframed operational spec.
|
§"CallClient" for the reframed operational spec.
|
||||||
|
|
||||||
|
## Amendment (2026-10-13 — review 010 S-04: announced Sub/Pub forward-through, marked-spec refusal)
|
||||||
|
|
||||||
|
`op/register`'s announced Sub/Pub handling shipped as invocation-failing
|
||||||
|
stubs (`INVALID_OPERATION_TYPE` on every invocation of an advertised,
|
||||||
|
discoverable op — review 010 S-04, nine published versions). The
|
||||||
|
amendment replaces the stubs with the real forwarding machinery and
|
||||||
|
closes the marked-spec hole the stubs sat in:
|
||||||
|
|
||||||
|
1. **Unmarked `Sub` announcements** register as real
|
||||||
|
`HandlerKind::Stream` handlers
|
||||||
|
(`make_streaming_forwarding_handler`) — a nested composition that
|
||||||
|
cannot invoke them anyway (`env.invoke` is request/response-only,
|
||||||
|
ADR-046 §6), but the overlay now carries a genuine working forward
|
||||||
|
of the remote stream, not an advertised-always-failing body.
|
||||||
|
2. **Unmarked `Pub` announcements** register as real
|
||||||
|
`HandlerKind::Sink` handlers (`make_sink_forwarding_handler`) —
|
||||||
|
same posture; the shapes already exist in-tree (`from_call`'s own
|
||||||
|
import path).
|
||||||
|
3. **Marked (`channel_open`) announcements — any type — are refused at
|
||||||
|
`op/register`** with a typed error (`MARKED_SPEC_UNSUPPORTED`):
|
||||||
|
marked ops are channel-machinery ops by their own marker contract
|
||||||
|
(ADR-047 §2 — the data plane is the allocated channel's
|
||||||
|
`BiStream`). Forwarding a marked op is incoherent by the marker's
|
||||||
|
own semantics — a `Sub`-side forward would deliver the
|
||||||
|
`{channel_id}` ack of a channel that exists only on the remote
|
||||||
|
side; a `Pub`-side forward would stream call-plane chunks into a
|
||||||
|
surface alkcall's amendment 4 rejects. The announcing peer's
|
||||||
|
sanctioned compositions are the relay
|
||||||
|
(`ChannelRelay::register_relay_openable`) for a hub, and direct
|
||||||
|
consumption (`ChannelClient`) otherwise. The same refusal applies
|
||||||
|
to overlay imports generally: `CallConnection::register_imported`
|
||||||
|
and the generic registration path refuse marked specs that did not
|
||||||
|
come through the channel-machinery seam (ADR-047 amendment 4
|
||||||
|
§4.8), so a marked spec can no longer land anywhere as an
|
||||||
|
advertised-always-failing or advertised-but-unusable body.
|
||||||
|
|
||||||
|
The stub-era rationale ("nested composition is request/response-only;
|
||||||
|
the streaming/sink forwarding shapes ride on the `from_call` import
|
||||||
|
path") explained why the *stub body* existed, not why the *announcement
|
||||||
|
was accepted*; it was reviewed and rejected in review 010 (S-04) — the
|
||||||
|
rationale may justify not implementing nested streaming composition,
|
||||||
|
but not accepting and advertising an announcement that cannot serve it.
|
||||||
|
|
||||||
|
Door type: one-way. The refusal code and the marked-spec overlay
|
||||||
|
invariant are wire-visible (announcing peers observe the rejection) and
|
||||||
|
join the stable error set per ADR-016. The handler-kind mapping for
|
||||||
|
unmarked announcements reuses the existing forwarding shapes — no new
|
||||||
|
surface.
|
||||||
|
|
||||||
|
References: review 010 S-04 (the stub),
|
||||||
|
`src/registry/op_register.rs` (the handler), ADR-047 amendment 4 §4.8
|
||||||
|
(the marked-spec registration invariant this enforces on the wire
|
||||||
|
side), ADR-046 §6 (the composition boundary), ADR-051 (the relay
|
||||||
|
composition marked announcements are refused in favor of).
|
||||||
@@ -14,7 +14,265 @@ per-op `description` on the listing, the dynamic half stays deferred
|
|||||||
— see "Amendment (§6 listing enrichment, 2026-09-06)" below;
|
— see "Amendment (§6 listing enrichment, 2026-09-06)" below;
|
||||||
amendment 3 (2026-09-16, review 008 U-1) — the op-name convention
|
amendment 3 (2026-09-16, review 008 U-1) — the op-name convention
|
||||||
extends to the flavor form and discovery carries an explicit
|
extends to the flavor form and discovery carries an explicit
|
||||||
`channel_open_alpn` for non-derivable names — see "Amendment 3" below)
|
`channel_open_alpn` for non-derivable names — see "Amendment 3" below;
|
||||||
|
amendment 4 (2026-10-13, reviews 010 S-01 / 011) — the marked-pub open
|
||||||
|
contract: the initiator-allocated channel, the adopt path, the wrapper
|
||||||
|
as responder of record, zero `call.published` on marked ops, the relay
|
||||||
|
translation, and the client-side put-open capability — see "Amendment 4"
|
||||||
|
below)
|
||||||
|
|
||||||
|
## Amendment 4 (the marked-pub open contract, 2026-10-13 — review 010 S-01, review 011)
|
||||||
|
|
||||||
|
`channels/<alpn>/pub` open ops were registered, advertised, and stubbed
|
||||||
|
(`channel:pub_open_not_implemented` on every invocation — review 010
|
||||||
|
S-01, shipped nine published versions). This amendment pins the
|
||||||
|
implemented contract in place of the stub; review 011 corrected the
|
||||||
|
remediation sketch before this amendment was drafted (the serving-loop
|
||||||
|
`PublishStream` shape is the **unmarked** pub semantics and does not
|
||||||
|
apply to marked ops).
|
||||||
|
|
||||||
|
The shape, in one sentence: **a marked pub open is an ordinary channel
|
||||||
|
open whose `channel_id` the initiator allocated on its own side** — the
|
||||||
|
initiator streams bytes on that channel before the open resolves (the
|
||||||
|
early-arrival parking covers the race), and the responder adopts and
|
||||||
|
serves the data plane as the responder of record.
|
||||||
|
|
||||||
|
### 4.1 Marker semantics (pin 1)
|
||||||
|
|
||||||
|
The marked pub op's data plane is the adopted channel's `BiStream` —
|
||||||
|
one direction, initiator → responder, with the ADR-040 bounded buffer
|
||||||
|
as the backpressure mechanism. **Zero `call.published` data on the call
|
||||||
|
plane for marked pub ops**: a nonempty publish-chunk stream on a marked
|
||||||
|
op is rejected loudly with a new wire error (code
|
||||||
|
`channel:publish_chunks_rejected`, retryable false, message stating
|
||||||
|
that the data plane is the adopted channel) written in the initiator's
|
||||||
|
direction when the first chunk arrives; the chunks are never consumed,
|
||||||
|
and the marked-pub flow continues to its own single response. Two
|
||||||
|
competing data paths on a marked op must never exist. Unmarked `Pub`
|
||||||
|
ops are unaffected — they remain the ADR-046 `call.published` stream
|
||||||
|
through `publish_schema` validation.
|
||||||
|
|
||||||
|
### 4.2 `publish_schema` refused at registration for marked ops (pin 2)
|
||||||
|
|
||||||
|
Publish chunks never exist on a marked op (§4.1), so `publish_schema`
|
||||||
|
is dead machinery on a marked spec — the registered-but-incapable-of-
|
||||||
|
mattering class review 010 outlaws. `OperationRegistry::register`
|
||||||
|
refuses a marked spec that carries `publish_schema` (registration-time
|
||||||
|
loudness, per the house pattern).
|
||||||
|
|
||||||
|
### 4.3 Reserved input key + dispatch-layer strip (pin 3)
|
||||||
|
|
||||||
|
The adopted channel id crosses the open invocation as the reserved
|
||||||
|
input key **`"channel_id"`** — the same key the wrapper owns on the
|
||||||
|
reply side (`RESERVED_REPLY_KEY`, ADR-049 amendment 3); the wrapper
|
||||||
|
owns the `channel_id` key on both sides of a marked open op. Placement
|
||||||
|
rationale (review 011 §1.2): schema validation runs in `invoke_*`
|
||||||
|
before any handler, so the reserved key must be stripped
|
||||||
|
**pre-validation** in the sink resolve path (`resolve_sink_handler` —
|
||||||
|
the single source of truth shared by the wire dispatch and
|
||||||
|
registry-level `invoke_sink`, per review 001 P-08) before
|
||||||
|
`input_schema` validation; the stripped id is handed to the wrapper via
|
||||||
|
`OperationContext.metadata` under the key `channel_open_channel_id`.
|
||||||
|
The producing crate's `input_schema` never contains the key, and
|
||||||
|
dispatch rejects a marked pub op whose input **lacks** it — the
|
||||||
|
initiator-allocated id is mandatory, not optional plumbing
|
||||||
|
(`INVALID_INPUT`, message naming the key). An op input carrying
|
||||||
|
`channel_id` on a NON-pub or unmarked op is not stripped (their
|
||||||
|
schemas own their full input surface; the strip and the requirement are
|
||||||
|
both scoped to marked pub ops).
|
||||||
|
|
||||||
|
### 4.4 The wrapper as responder of record (pin 4)
|
||||||
|
|
||||||
|
The marked-pub open registration is
|
||||||
|
`ChannelCore::register_pub_openable(spec, establisher, pub_open_handler,
|
||||||
|
registry, auth, timeout)`. The entry points refuse wrong shapes at
|
||||||
|
registration: the `Query`/`Mutation`/`Sub` wrapper entry points
|
||||||
|
(`register_openable*`) refuse `Pub`-typed specs, and
|
||||||
|
`register_pub_openable` refuses non-`Pub` or unmarked specs. The
|
||||||
|
handler type is a separate one from `OpenHandler`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub type PubOpenHandler = Arc<
|
||||||
|
dyn Fn(Value, Option<ChannelPlan>, Connection, AuthContext)
|
||||||
|
-> BoxFuture<'static, Result<Value, CallError>>
|
||||||
|
+ Send + Sync,
|
||||||
|
>;
|
||||||
|
```
|
||||||
|
|
||||||
|
(`JoinHandle<()>` — the `OpenHandler` return — cannot carry the
|
||||||
|
handler result the merged response needs; see §4.6 for why this is a
|
||||||
|
separate type rather than an `OpenHandler` change.)
|
||||||
|
|
||||||
|
The wrapper flow (the responder of record):
|
||||||
|
|
||||||
|
1. `policy.check_open(identity)` — unchanged (cap gate, pre-adopt).
|
||||||
|
2. **Adopt** the initiator-allocated id:
|
||||||
|
`manager.adopt_channel(reserved_id, alpn, None)` — the existing
|
||||||
|
demux/mux install + early-arrival drain; no new machinery. Adoption
|
||||||
|
failure (collision or cap) tears nothing down (nothing was created)
|
||||||
|
and replies `channel:open_failed` with a new reason **`adopt_failed`**
|
||||||
|
in the ADR-049 §3 vocabulary.
|
||||||
|
3. The establisher (bounded, ADR-049 §1/§2 machinery, unchanged) may
|
||||||
|
validate the offer and contribute ack fields
|
||||||
|
(`Establishment.reply_fields`); its plan threads to the handler.
|
||||||
|
4. Spawn the pub handler on the channel's `Connection` — the wrapper
|
||||||
|
**awaits the handler inline** (review 011 §1.3), which is what makes
|
||||||
|
R-02's lifetime contract structural here: the handler cannot return
|
||||||
|
before its pumps finish, so the teardown-at-birth trap does not
|
||||||
|
exist on this path, and the channel's `ChannelState.handler_task`
|
||||||
|
stays `None` (teardown is EOF-driven: a `channel/close` during the
|
||||||
|
put drops the demux sender, the handler's reads see EOF, the handler
|
||||||
|
finishes early, and the single response is still written — the
|
||||||
|
handler decides the semantics of a truncated stream).
|
||||||
|
5. **One response, merge order pinned:** `{ "channel_id": <adopted> }`
|
||||||
|
→ extended with the establisher's ack fields (ADR-049 amendment 3,
|
||||||
|
unchanged, including the reserved-key establisher-bug refusal) →
|
||||||
|
extended with the handler's result fields (handler result wins on
|
||||||
|
key collision — its computed result postdates the ack). A handler
|
||||||
|
returning `Err` responds that error directly and the channel tears
|
||||||
|
down; racing initiator bytes hit EOF and are discarded.
|
||||||
|
6. The standard completion teardown runs after the await (demux
|
||||||
|
sender drop, ledger take, `policy.on_close` — the same atomic gate
|
||||||
|
every teardown path runs, ADR-047 §7).
|
||||||
|
|
||||||
|
A marked pub op registered outside the wrapper family (a plain
|
||||||
|
`SinkHandler` on a marked pub spec) can never adopt — the bytes would
|
||||||
|
park and drop. That class is refused at registration (§4.8).
|
||||||
|
|
||||||
|
### 4.5 The ack-timing sentence (pin 5 — protocol semantics)
|
||||||
|
|
||||||
|
**A sink op gets exactly one `call.responded`, produced after the
|
||||||
|
handler completes — so a putter stream-waits nothing: the
|
||||||
|
establishment ack (the adopt + establishment reply fields) rides
|
||||||
|
informatively in that one result, never as a gating ack before the
|
||||||
|
byte stream.** The fetch/put asymmetry is direction-inherent: fetch
|
||||||
|
(`Sub`) acks at establishment — the `{channel_id}` response precedes
|
||||||
|
any byte (and the `{channel_id}` is the ack); put (`Pub`) acks in the
|
||||||
|
result. This is protocol semantics, not per-consumer folklore: a
|
||||||
|
consumer protocol layered on marked pub ops (alkblobs' put) cannot
|
||||||
|
wait for the ack without deadlocking (the response cannot exist until
|
||||||
|
the stream completes), and the amendment states it so no consumer
|
||||||
|
re-derives it.
|
||||||
|
|
||||||
|
### 4.6 Why `PubOpenHandler` is a separate type (pin record)
|
||||||
|
|
||||||
|
The alternative — changing `OpenHandler`'s return type — breaks every
|
||||||
|
published consumer's open handlers (alktty, alkhttp, alktunnels,
|
||||||
|
alkty, alksocks all register `Sub`-type openables) for zero benefit:
|
||||||
|
the `()`-returning shape is correct for the spawn-and-reconcile `Sub`
|
||||||
|
path (the channel outlives the open call; the wrapper never awaits
|
||||||
|
it), while the put path needs completion-in-response. The split keeps
|
||||||
|
S-01 capability-additive (review 011 §5: no shipped consumer registers
|
||||||
|
a `Pub`-typed openable); the put path's type is new API surface,
|
||||||
|
one-way like its siblings (ADR-046's handler-type door).
|
||||||
|
|
||||||
|
### 4.7 Relay translation replaces `PubTypedOpen` (pin 6)
|
||||||
|
|
||||||
|
`RelayError::PubTypedOpen` is removed. The hub's pub-openable
|
||||||
|
translation (the put-forwarding shape):
|
||||||
|
|
||||||
|
- The consumer-leg registration composes
|
||||||
|
`ChannelCore::register_pub_openable` with a relay establisher: the
|
||||||
|
hub **allocates** its connect-side id on the producer leg
|
||||||
|
(`open_channel` — odd, the implemented split), sends the open op
|
||||||
|
carrying the reserved key to the spoke **in the background** (never
|
||||||
|
awaited inside the establisher — the spoke's put response cannot
|
||||||
|
exist before its byte stream completes; awaiting it in the
|
||||||
|
establisher deadlocks the leg), and the `RelayPlan` carries the
|
||||||
|
spoke-leg send half, the early-arrival-drained read half, the
|
||||||
|
producer-leg entry reclaim guard (as before, ADR-051), and the
|
||||||
|
response receiver.
|
||||||
|
- The relay pub handler (the consumer-leg pub handler): pumps the
|
||||||
|
consumer-leg channel bytes → spoke send half (one direction, pub);
|
||||||
|
on consumer EOF, EOFs the spoke side; awaits the spoke's single
|
||||||
|
response; strips the producer's reserved reply key from the spoke
|
||||||
|
output (the hub's reply must carry the hub-allocated consumer-leg
|
||||||
|
id — a spoke `channel_id` merged through would shadow it, the same
|
||||||
|
reserved-key class the wrapper refuses) and returns the remainder as
|
||||||
|
the handler result — which the consumer-leg wrapper merges into the
|
||||||
|
single response with the pinned order.
|
||||||
|
- The spoke's open-time establishment failure surfaces in the final
|
||||||
|
response (put-acks-in-result), mapped through the existing
|
||||||
|
`map_spoke_error` table; the plan's reclaim guard tears down the
|
||||||
|
producer-leg entry on every handler path, as ADR-051 §6 already
|
||||||
|
pins for the sub direction.
|
||||||
|
- The hub-leg template's pub-typed assembly error becomes unreachable
|
||||||
|
(registered through the relay like the sub ones).
|
||||||
|
|
||||||
|
### 4.8 The registration invariants (the stub-class guard)
|
||||||
|
|
||||||
|
`OperationRegistry::register` enforces, at registration time:
|
||||||
|
|
||||||
|
- A marked spec with `publish_schema` → refused (§4.2).
|
||||||
|
- A marked spec registered through the generic path → refused: marked
|
||||||
|
specs are registered only through the channel-machinery seam
|
||||||
|
(`register_openable*`, `register_pub_openable`, the relay). The
|
||||||
|
wrapper-family registrations carry an internal registration flag the
|
||||||
|
generic paths cannot set; `CallConnection::register_imported` (the
|
||||||
|
overlay landing) and direct `registry.register` both hit the refusal
|
||||||
|
— for overlay imports because a marked op is unreachable by
|
||||||
|
composition (`OperationEnv::invoke` is request/response-only; the
|
||||||
|
marker's data plane has no overlay path), for direct registrations
|
||||||
|
because the channel machinery would silently not exist.
|
||||||
|
|
||||||
|
### 4.9 The client-side initiator-allocates capability (pin 7)
|
||||||
|
|
||||||
|
`ChannelClient::open_pub_channel(operation_id, input, alpn)`: allocates
|
||||||
|
the connect-side odd id locally (`open_channel` on its own manager),
|
||||||
|
inserts the reserved input key, sends the open op **in a background
|
||||||
|
task**, and returns `(id, send, recv, reply)` immediately — the caller
|
||||||
|
writes bytes on `send` without waiting for anything (put acks in
|
||||||
|
result, §4.5). `reply` is the awaited single merged response
|
||||||
|
(`Ok(Value)` — the full output; `Err(ChannelOpenError)` — the wire
|
||||||
|
error passthrough, with the local adopt entry torn down on failure).
|
||||||
|
The existing `open_channel`/`open_channel_with_reply` await the reply
|
||||||
|
before returning the halves — unusable for a put; this is the
|
||||||
|
putter-facing shape the wrapper is untestable end-to-end without.
|
||||||
|
|
||||||
|
### Door type
|
||||||
|
|
||||||
|
**One-way (wire-visible).** The reserved input key and its
|
||||||
|
strip-before-validation, the missing-key rejection, the
|
||||||
|
chunk-rejection code, the merge order, the single-response semantics,
|
||||||
|
and the ack-timing sentence are wire-visible from the first consumer
|
||||||
|
(alkblobs' put — which pends behind this amendment, so this is the
|
||||||
|
cheapest moment to pin; no shipped consumer rides any of it, review
|
||||||
|
011 §5). `PubOpenHandler`, `register_pub_openable`,
|
||||||
|
`open_pub_channel`, and the `adopt_failed` reason are new API surface,
|
||||||
|
one-way per the handler-API precedent. The early-arrival parking, the
|
||||||
|
wrapper internals, and the relay plan internals are two-way-door
|
||||||
|
details inside the one-way contract.
|
||||||
|
|
||||||
|
### Verification gates (implementation units, review 011 §3)
|
||||||
|
|
||||||
|
- known-length put e2e: offer + adopt + bytes → one response,
|
||||||
|
`{channel_id, ack fields, handler result}` merge order verified;
|
||||||
|
- unknown-length put e2e: the handler's digest rides the merged
|
||||||
|
response;
|
||||||
|
- publish-chunk rejection: a marked pub op carrying nonempty publish
|
||||||
|
chunks → loud rejection, no handler consumption;
|
||||||
|
- establishment failure: `channel:open_failed`, channel torn down,
|
||||||
|
ledger un-incremented, racing bytes EOF-discarded;
|
||||||
|
- `publish_schema` registration refusal on a marked op;
|
||||||
|
- relay translation: a hub translating a pub openable end-to-end.
|
||||||
|
- plus: missing reserved key rejection; `open_pub_channel` happy +
|
||||||
|
wire-failure paths (the client capability's gates).
|
||||||
|
|
||||||
|
### References
|
||||||
|
|
||||||
|
- Review 010 S-01 (the four stubs; the implement-or-stop-advertising
|
||||||
|
ruling), review 011 (the corrected shape this amendment pins)
|
||||||
|
- alkblobs ADR-014 (`014-marked-pub-put-contract-and-alkcall-gate`) —
|
||||||
|
the downstream ruling this amendment unblocks
|
||||||
|
- ADR-046 (the unmarked pub semantics that marked ops do NOT ride),
|
||||||
|
ADR-049 (establishment machinery + `RESERVED_REPLY_KEY` + merge),
|
||||||
|
ADR-040 (bounded buffer — the data-plane backpressure), ADR-050
|
||||||
|
(pumps), ADR-051 (the relay this amendment extends to pub), ADR-043
|
||||||
|
(`ChannelClient` — the `open_pub_channel` home)
|
||||||
|
- `src/channels/manager.rs` (`next_channel_id` odd/even;
|
||||||
|
`drain_early_arrivals`), `src/channels/operations.rs` (the wrapper
|
||||||
|
family), `src/channels/relay.rs`, `src/registry/registration.rs`
|
||||||
|
(`resolve_sink_handler` — the strip site)
|
||||||
|
|
||||||
## Amendment 3 (flavor-form open-op ids + explicit `channel_open_alpn`, 2026-09-16 — review 008 U-1)
|
## Amendment 3 (flavor-form open-op ids + explicit `channel_open_alpn`, 2026-09-16 — review 008 U-1)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# ADR-052: The `channel/control` Control-Handle Surface
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted — amends ADR-037 (`channel/control`'s contract) and ADR-047
|
||||||
|
(amended §1: the generic ops stay). Resolves review 010 S-02 (the
|
||||||
|
`channel/control` stub shipped nine published versions; the stub was
|
||||||
|
pinned by tests and tracked as OQ-39) by implementing the surface the
|
||||||
|
ADR-037 text always promised. The stub's tracked design work (OQ-39)
|
||||||
|
is promoted here verbatim-ish: ALPN-crate-registered per-channel
|
||||||
|
control callbacks, a channels-layer callback registry keyed by
|
||||||
|
`channel_id`.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-037 specified `channel/control` as the out-of-band control op on
|
||||||
|
channel 0 (resize, signal, keepalive — anything that does not need
|
||||||
|
ordering relative to data), with the channels layer routing the
|
||||||
|
ALPN-specific `message` to a handler's control handle. The
|
||||||
|
implementation shipped a registered, discoverable stub that checked
|
||||||
|
`channel` existence and then returned `channel:control_not_implemented`
|
||||||
|
on every invocation — which perversely returned *not found* for unknown
|
||||||
|
channels and *not implemented* for real ones (review 010 S-02). The
|
||||||
|
implementation was tracked as OQ-39 with the shape recorded; nothing
|
||||||
|
blocked it except that no ALPN crate had asked yet. alktty's move of
|
||||||
|
its control surface to the channels path (ADR-049 §5's migration list)
|
||||||
|
and the general stub ruling (review 010) make that answer invalid.
|
||||||
|
|
||||||
|
The design constraint from ADR-039: `ChannelManager` is auth-blind,
|
||||||
|
ALPN-blind, transport-blind. The control-handle surface must not
|
||||||
|
reintroduce ALPN interpretation into the manager.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. The control callback type and registry
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub type ChannelControl = Arc<
|
||||||
|
dyn Fn(Value) -> BoxFuture<'static, Result<Value, CallError>> + Send + Sync,
|
||||||
|
>;
|
||||||
|
```
|
||||||
|
|
||||||
|
The callback receives the `message` value (ALPN-specific JSON; the
|
||||||
|
channels layer does not interpret it) and returns the ALPN-specific
|
||||||
|
reply (or a `CallError` it wants surfaced verbatim — e.g.
|
||||||
|
`target_gone`). The live handle: `HashMap<u32, ChannelControl>` in the
|
||||||
|
`ChannelManager`'s internals (the same map discipline as `channels`;
|
||||||
|
`parking_lot` mutex).
|
||||||
|
|
||||||
|
The manager stays blind to what a callback does — it stores opaque
|
||||||
|
closures, exactly as `ChannelState` already stores opaque `handler_task`
|
||||||
|
handles.
|
||||||
|
|
||||||
|
### 2. Registration: the establisher contributes the control handle
|
||||||
|
|
||||||
|
The ALPN crate registers a control callback by returning it from its
|
||||||
|
open-op establisher: `Establishment::with_control(callback)` (builder,
|
||||||
|
alongside `with_reply_field` — ADR-049 amendment 3). The **wrapper**
|
||||||
|
installs it under the allocated/adopted id
|
||||||
|
(`manager.set_control_handle(id, callback)`) and every teardown path
|
||||||
|
removes it (`teardown_channel` drops the entry; `clear_all` drains
|
||||||
|
them) — same closure-lifecycle discipline as the opener ledger.
|
||||||
|
|
||||||
|
Why the establisher, not the handler: the establisher already returns
|
||||||
|
typed payloads the wrapper routes (`Establishment`), it runs where the
|
||||||
|
ALPN crate has prepared its backend (the resize target is the backend
|
||||||
|
the establisher dialled/planned), and adding a registration path
|
||||||
|
through the `OpenHandler` would require handing the handler the
|
||||||
|
manager and the id — a signature break across every published consumer
|
||||||
|
for zero benefit. The wrapper-owned installation also makes the
|
||||||
|
callback's lifetime exactly the channel's lifetime by construction.
|
||||||
|
|
||||||
|
### 3. The op: routing and error surface
|
||||||
|
|
||||||
|
`make_control_handler` (the ADR-037 op, registered by
|
||||||
|
`ChannelOps::register_on`) becomes: validate `channel_id` (unchanged)
|
||||||
|
→ `channel` exists? (unchanged; `channel:unknown_channel`) → callback
|
||||||
|
registered for the id? →
|
||||||
|
|
||||||
|
| case | reply |
|
||||||
|
|------|-------|
|
||||||
|
| unknown channel | `channel:unknown_channel` (existing class) |
|
||||||
|
| no callback registered | `channel:no_control_handle` — typed, retryable false, message: the channel's ALPN registered no control surface (`details: { alpn }`) |
|
||||||
|
| callback ok | `{ "control": <callback reply> }` |
|
||||||
|
| callback err | the callback's `CallError` verbatim |
|
||||||
|
|
||||||
|
`channel:no_control_handle` is a real capability answer, not a stub:
|
||||||
|
a consumer that calls `channel/control` on a channel whose ALPN chose
|
||||||
|
not to expose control learns exactly that. The `message` body is
|
||||||
|
ALPN-specific JSON (unchanged; the channels layer does not read it).
|
||||||
|
|
||||||
|
**The callback receives the message only** — no identity, no
|
||||||
|
`OperationContext`. The authorization home is the op's own
|
||||||
|
`access_control` (checked by the registry before the handler, per
|
||||||
|
ADR-010/017 — the same op-level ACL every other surface runs); a
|
||||||
|
per-owner resize is expressed as ADR-011 `resource_id_path` on the
|
||||||
|
`channel/control` spec for ALPNs that want it, not by widening the
|
||||||
|
callback signature. Wire-visibility: `channel:no_control_handle`
|
||||||
|
joins the stable error set (ADR-016 — consumers branch on it
|
||||||
|
deliberately); the callback's reply shape is the producing crate's
|
||||||
|
schema concern.
|
||||||
|
|
||||||
|
### 4. What this does not decide
|
||||||
|
|
||||||
|
Ordering-sensitive control rides the data channel (unchanged —
|
||||||
|
channel-operations.md's control-message division, DP-4 pinned). The
|
||||||
|
manager keeps its blindness; the enumerators registry of ADR-053 is
|
||||||
|
the analogous ALPN-level surface for discovery, not control.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:** the advertised op serves; S-02 dies; ALPN crates get
|
||||||
|
out-of-band control for free (resize/signal arrive as one routed
|
||||||
|
call-op instead of per-crate in-band framing); teardown-bound callback
|
||||||
|
lifetime is structural.
|
||||||
|
|
||||||
|
**Negative:** the manager's internals gain one map (opaque closures —
|
||||||
|
no blindness regression); a control callback racing teardown can
|
||||||
|
observe a torn-down backend through its own error (`CallError`) — the
|
||||||
|
same shape the handler EOF path already produces, not a new class.
|
||||||
|
|
||||||
|
## Door type
|
||||||
|
|
||||||
|
**One-way (wire-visible).** `channel:no_control_handle` and the reply
|
||||||
|
envelope shape join the stable surface once consumers branch on them.
|
||||||
|
The callback type, the establisher carrier, and the map are two-way-door
|
||||||
|
implementation details inside it (the `Establishment` carrier is
|
||||||
|
`#[non_exhaustive]` — ADR-049 amendment 2's posture).
|
||||||
|
|
||||||
|
## Verification gates
|
||||||
|
|
||||||
|
- `channel/control` routes to the establisher-contributed callback and
|
||||||
|
the reply is the callback's output (e2e over a real channels
|
||||||
|
connection);
|
||||||
|
- `channel:no_control_handle` on a channel whose ALPN registered none;
|
||||||
|
- `channel:unknown_channel` preserved (the pre-existing error);
|
||||||
|
- teardown removes the handle (post-teardown control answers
|
||||||
|
`channel:unknown_channel`, never a stale callback);
|
||||||
|
- callback `Err` passes through verbatim.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Review 010 S-02 (the stub; the implement ruling), review 011 §4
|
||||||
|
(downstream-safe; implement preferred)
|
||||||
|
- OQ-39 (the promoted design note this ADR resolves)
|
||||||
|
- ADR-037 (the op's contract), ADR-047 (openable ALPNs; the wrapper
|
||||||
|
this registration rides), ADR-049 (the establisher carrier this
|
||||||
|
registers through), ADR-039 (manager blindness), ADR-011
|
||||||
|
(`resource_id_path` — the per-owner control-gate lever)
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# ADR-053: `channel/resources/subscribe` — Live Resource Enumerators
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted — implements ADR-037 §6's dynamic half and ADR-047 §6's
|
||||||
|
discovery split ("what is currently there"). Resolves review 010 S-03
|
||||||
|
(the `channel/resources/subscribe` stub: a fake one-element error
|
||||||
|
stream shipped nine published versions, pinned by tests, tracked as
|
||||||
|
OQ-40) by implementing the aggregated live subscription. OQ-40's
|
||||||
|
promotion: each ALPN crate provides a resource enumerator; the
|
||||||
|
channels layer aggregates them into a live `Stream`.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The static half is covered (ADR-047's 2026-09-06 amendment:
|
||||||
|
`OperationSpec.description` on `services/list` describes the op — not
|
||||||
|
the produced resource set). The dynamic half — "which tunnel resources
|
||||||
|
may I open, live" — was deferred (OQ-40) and then shipped as a stub; a
|
||||||
|
consumer discovering the op got a capability that does not exist
|
||||||
|
(review 010 S-03). The load-bearing consumer (alktunnels' discovery UI,
|
||||||
|
review 006 E-02) uses config-known op names today, but the stub ruling
|
||||||
|
makes deferral unavailable as a posture: registered means implementable.
|
||||||
|
|
||||||
|
ADR-037 §6 + ADR-047 §6 pinned the shape: each ALPN crate that
|
||||||
|
registers open ops provides a resource enumerator (which containers
|
||||||
|
are running; which TTY sessions are active); `channel/resources/subscribe`
|
||||||
|
aggregates across registered openable ALPNs; the data source lives in
|
||||||
|
the ALPN crate. What was never pinned: the registration seam, the
|
||||||
|
enumerator contract, and the aggregation semantics. This ADR pins them.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. The enumerator contract
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub type ResourceStream = Pin<Box<dyn Stream<Item = Value> + Send>>;
|
||||||
|
|
||||||
|
pub trait ResourceEnumerator: Send + Sync + 'static {
|
||||||
|
/// Snapshot-then-changes: the first item is the ALPN's complete
|
||||||
|
/// current resource payload; each subsequent item replaces it
|
||||||
|
/// (a full snapshot, not a delta). The channels layer does not
|
||||||
|
/// interpret the payload.
|
||||||
|
fn subscribe(&self) -> ResourceStream;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each item is the **ALPN's full current resource payload** — an object
|
||||||
|
merged with `"alpn"` into the wire entry (`{ "resources": [{ "alpn":
|
||||||
|
"alk/tty", ...payload }] }`; a non-object payload wraps as
|
||||||
|
`{ "alpn": …, "data": … }`). The snapshot-then-changes contract lives
|
||||||
|
in the producing crate (a `watch` channel or similar is its own
|
||||||
|
machinery) — the channels layer never polls an enumerator mid-life.
|
||||||
|
|
||||||
|
### 2. Registration seam
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct ResourceEnumerators; // channels-call; RwLock<HashMap<String /*alpn*/, Arc<dyn ResourceEnumerator>>>
|
||||||
|
|
||||||
|
impl ResourceEnumerators {
|
||||||
|
pub fn new() -> Self;
|
||||||
|
pub fn register(&self, alpn: &str, enumerator: Arc<dyn ResourceEnumerator>);
|
||||||
|
pub fn alpns(&self) -> Vec<String>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Registered by the composing crate at assembly/install time (the same
|
||||||
|
seam that composes the policy and the openables — the composer has the
|
||||||
|
backend handles). The registry is shared per accepting peer (like
|
||||||
|
`PerIdentityChannelPolicy`, ADR-041): one `Arc<ResourceEnumerators>`
|
||||||
|
across every channels connection that peer accepts.
|
||||||
|
|
||||||
|
**Scan-once:** the merged stream's enumerator set is scanned when the
|
||||||
|
subscription starts; ALPNs registered later are invisible to in-flight
|
||||||
|
subscriptions (re-subscribe to see them). Registrations happen at
|
||||||
|
install time, before any subscription — a late-registration notify
|
||||||
|
machinery is machinery nobody needs, and this pin prevents a future
|
||||||
|
half-design of it.
|
||||||
|
|
||||||
|
### 3. The op: aggregation semantics
|
||||||
|
|
||||||
|
`ChannelOperations::with_resource_enumerators(manager, policy,
|
||||||
|
enumerators)` threads the registry to the aggregate handler; the plain
|
||||||
|
`ChannelOperations::new(manager, policy)` constructor keeps an empty
|
||||||
|
registry. `make_resources_subscribe_handler` (the `Sub` op — ADR-021
|
||||||
|
machinery, unchanged) becomes a real subscription:
|
||||||
|
|
||||||
|
- **Non-empty registry:** merge every registered enumerator's stream
|
||||||
|
(`futures::stream::select_all`), each item mapped to a one-entry
|
||||||
|
`{ "resources": [{ "alpn": <alpn>, ...payload }] }` envelope. The
|
||||||
|
first events across the ALPNs are the initial snapshot (each
|
||||||
|
enumerator's first item); subsequent events are the live updates.
|
||||||
|
- **Empty registry:** one `{ "resources": [] }` envelope, then the
|
||||||
|
stream stays open (the poll use case — subscribe, read the first
|
||||||
|
event, cancel — gets its snapshot; per the scan-once pin, nothing
|
||||||
|
further arrives until re-subscribe).
|
||||||
|
- **Zero enumerators ever registered but the op discoverable** is
|
||||||
|
impossible to distinguish from "no resources right now" — by design:
|
||||||
|
the static half answers "which ALPNs accept open ops"
|
||||||
|
(`services/list`), the dynamic half answers "what is currently
|
||||||
|
there"; a peer registering openables without enumerators reports an
|
||||||
|
empty live resource set, which is the truthful answer for "the
|
||||||
|
channels layer does not interpret your resources" (ADR-035 posture).
|
||||||
|
|
||||||
|
The `access` preview stays dropped (ADR-047 §6 — the spec is the
|
||||||
|
authority). The documented wire shape (channel-operations.md) is
|
||||||
|
unchanged — this is the implementation of the documented op, not a new
|
||||||
|
wire surface.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:** the advertised op serves (S-03 dies); ALPN crates carry
|
||||||
|
their resource truth themselves (the channels layer stays ALPN-blind —
|
||||||
|
it maps and merges opaque values); the hub/dashboard consumer gets the
|
||||||
|
live subscription ADR-037 promised.
|
||||||
|
|
||||||
|
**Negative:** `ChannelOperations` gains a second constructor (additive
|
||||||
|
— `new` keeps the empty default); an ALPN crate that never registers
|
||||||
|
an enumerator serves an always-empty resource set — truthful, and
|
||||||
|
`services/list` still discloses the ops (the static half is not
|
||||||
|
encumbered by the dynamic one).
|
||||||
|
|
||||||
|
## Door type
|
||||||
|
|
||||||
|
**Two-way (implementation shape) inside the documented wire surface.**
|
||||||
|
The wire shape (`{ "resources": [...] }`, per-ALPN entries, no
|
||||||
|
`access` preview) is ADR-037/047's pre-existing one-way surface, now
|
||||||
|
actually served. The enumerator trait, the scan-once rule, the empty
|
||||||
|
seed, and the registration seam are revisable implementation details —
|
||||||
|
no consumer observes them beyond the stream of envelopes.
|
||||||
|
|
||||||
|
## Verification gates
|
||||||
|
|
||||||
|
- initial snapshot: N registered enumerators → N first events carrying
|
||||||
|
each ALPN's payload merged with `"alpn"`;
|
||||||
|
- live update: an enumerator's second item arrives as the next envelope
|
||||||
|
without re-subscription;
|
||||||
|
- empty registry: exactly one `{ "resources": [] }` envelope, stream
|
||||||
|
stays open, subscriber cancel works (abort cascade, ADR-020);
|
||||||
|
- late registration invisibility: a post-subscription registration does
|
||||||
|
not appear in an in-flight subscription (the scan-once pin);
|
||||||
|
- non-object payload wrapping.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Review 010 S-03 (the stub; the implement ruling), review 011 §4
|
||||||
|
(downstream-safe; implement preferred)
|
||||||
|
- OQ-40 (the promoted design note this ADR resolves; the "load-bearing
|
||||||
|
for alktunnels' discovery UI" note)
|
||||||
|
- ADR-037 §6 (the dynamic half's contract), ADR-047 §6 (the
|
||||||
|
discovery split; the 2026-09-06 amendment's static half)
|
||||||
|
- ADR-021 (the `StreamingHandler` machinery this op rides), ADR-039
|
||||||
|
(manager blindness — the enumerators' channels-call home), ADR-041
|
||||||
|
(the shared-per-peer construction pattern the registry follows)
|
||||||
Reference in new issue
Block a user