feat(review 008 Unit 2): flavor-form open-op ids in discovery (U-1, ADR-047 amendment 3)
Flavor-form open op ids (channels/tunnel/direct, channels/tunnel/ forwarded — alktunnels ADR-007/008) now survive discovery + the op/register announce path: the marker reconstructs, so a hub consuming through discovery wraps the op with relay machinery instead of the silent plain-forwarding-stub failure. Per the review-008 plan's combined (b)+(c) shape: - derive_alpn_from_op_name generalizes from strip-/sub|/pub to strip-last-segment (rsplit_once) — a strict superset: standard two-suffix shapes derive identically, multi-segment ALPNs survive the same way, and the flavor form derives. The boolean marker remains the gate (consulted only for marked ops in rebuild_spec_for) — a plain op named channels/tty/query is unaffected. - spec_to_json_pub emits channel_open_alpn (the explicit string) beside the boolean when the op name is not the standard channels/<seg>/(sub|pub) shape; standard shapes stay byte-identical to the pre-amendment payload. The advertised operation_spec_schema documents the optional ["string","null"] property. - rebuild_spec_for prefers the explicit string, else boolean → generalized derivation. Both wire consumers (from_call import, op/register announce) parse through the same parser, so one change covers both. A channel_open_alpn without the boolean never marks an op (the boolean is the dispatch hint). Residual ambiguity pinned in the ADR: an ALPN segment colliding with a flavor name (channels/x/direct → alk/x vs alk/x/direct) is undecidable from the name alone; the explicit string is the disambiguator. Tests (review gates 1 + 3; gate 2 lands with the Unit 3 relay): - channels/tunnel/direct reconstructs WITH channel_open = alk/tunnel, both via the explicit string and via the boolean alone (the deployment-skew case: old producer, new consumer) - standard shapes (channels/tty/sub, multi-segment channels/custom/proto/sub) round-trip byte-stable — no new key - explicit string overrides a colliding derivation; string without boolean is ignored - op/register announced flavor-form spec round-trips with the marker - derivation unit tests: flavor form, non-op-type suffix not special-cased, prior shapes unchanged Docs: ADR-047 amendment 3 — the convention sentence (mirroring alktunnels ADR-002 Amendment 1: flavors are new op ids, additive, the .../sub op is never reused), the wire shape, the preference order, the residual-ambiguity note, one-way-door timing. Verification: cargo test 649 passed (12 new); clippy -D warnings clean; cargo fmt --check clean; cargo doc --no-deps clean.
This commit is contained in:
@@ -11,7 +11,94 @@ base registry installed as the session's dispatch registry**, not the
|
||||
connection overlay — see "Amendment (§4 mechanism, 2026-09-03)" below;
|
||||
§6 amended 2026-09-06 — the static half of discovery gains an additive
|
||||
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
|
||||
extends to the flavor form and discovery carries an explicit
|
||||
`channel_open_alpn` for non-derivable names — see "Amendment 3" below)
|
||||
|
||||
## Amendment 3 (flavor-form open-op ids + explicit `channel_open_alpn`, 2026-09-16 — review 008 U-1)
|
||||
|
||||
alktunnels' graduation (ADR-002 Amendment 1 at
|
||||
`/workspace/@alkdev/alktunnels/docs/architecture/decisions/002-alpn-strategy.md`)
|
||||
pins two NEW `Sub`-typed open ops on the existing `alk/tunnel` ALPN
|
||||
with flavor-form op ids (`channels/tunnel/direct`,
|
||||
`channels/tunnel/forwarded`) — the ssh
|
||||
`direct-tcpip`/`forwarded-tcpip` shape: one ALPN, several channel
|
||||
flavors, one open op per flavor. §1 pinned the open-op naming to
|
||||
exactly two shapes per ALPN (`channels/<alpn>/sub`,
|
||||
`channels/<alpn>/pub`), and Gap F's marker reconstruction derived the
|
||||
ALPN by stripping exactly those two suffixes — so a flavor-form op id
|
||||
failed the derivation and a hub consuming through discovery rebuilt
|
||||
the spec WITHOUT the marker (the silent plain-forwarding-stub failure,
|
||||
the worst mode for a relay). Runtime was never blocked (registration
|
||||
sets the ALPN explicitly; `open_channel` takes it as an argument);
|
||||
discovery + hub relay are the re-produce path that must survive.
|
||||
|
||||
**The convention sentence (mirroring alktunnels ADR-002 Amendment 1):**
|
||||
`channels/<alpn>/<flavor>` is a valid open-op name where `<flavor>`
|
||||
is a bare path segment (no `/`), `Sub`-typed, served through the same
|
||||
establishment wrapper and relay path as `…/sub`. New flavors are new
|
||||
op ids — additive; the `…/sub` op is never reused for a different
|
||||
meaning. `OperationType` (`Sub`/`Pub`) continues to carry the
|
||||
direction; the flavor is the channel-type discriminator the producing
|
||||
crate owns (per-ALPN params semantics, per alktunnels ADR-001
|
||||
Amendment 1).
|
||||
|
||||
**The wire shape (Gap F refinement):** the boolean marker's
|
||||
round-trip is derivable only for the standard shapes. The rule:
|
||||
|
||||
- `spec_to_json_pub` keeps emitting `"channel_open": true` for every
|
||||
marked op (unchanged). When the op name is NOT the standard
|
||||
`channels/<segment>/(sub|pub)` shape, it ALSO emits
|
||||
`"channel_open_alpn": "<alpn>"` — the explicit string rides beside
|
||||
the boolean. Standard shapes stay byte-identical to the
|
||||
pre-amendment payload (no new key).
|
||||
- `rebuild_spec_for` prefers the explicit string when present; else
|
||||
(the boolean alone — the deployment-skew case of an old producer)
|
||||
the derivation generalizes from "strip `/sub`|`/pub`" to "strip the
|
||||
LAST path segment." The boolean marker remains the gate: the
|
||||
derivation is consulted only for marked ops, so a plain op named
|
||||
`channels/tty/query` is unaffected, and a `channel_open_alpn`
|
||||
string without the boolean never marks an op.
|
||||
- `services/schema`'s advertised `operation_spec_schema` documents
|
||||
`channel_open_alpn` as an optional `["string", "null"]` property
|
||||
(schema-type widening, additive — old consumers ignore unknown
|
||||
properties).
|
||||
- Both wire consumers of the shape — the `from_call` import and the
|
||||
`op/register` announced-spec path — parse through the same
|
||||
`rebuild_spec_for`, so one parser change covers both.
|
||||
|
||||
**Residual ambiguity, pinned:** an op name whose ALPN segment itself
|
||||
ends in a flavor-like name (`channels/x/direct` where the intended
|
||||
ALPN is `alk/x/direct`-shaped) is undecidable from the name alone —
|
||||
strip-last reads `alk/x`, and the true ALPN is unknowable from the
|
||||
name. The explicit `channel_open_alpn` string is the disambiguator:
|
||||
producing crates with non-`alk/*`-derivable names set it; the
|
||||
derivation is the fallback for derivable shapes. Old producers
|
||||
(alkcall ≤ 0.7.1) cannot serve flavor-form marked ops discoverably
|
||||
until upgraded — the review's one-way-door timing note.
|
||||
|
||||
Door type: the flavor-form names and the additive
|
||||
`channel_open_alpn` key are wire-stable from the first consumer
|
||||
(alksocks composes against them); the boolean's meaning for
|
||||
standard-shape ops is unchanged (the pre-amendment byte-stability is
|
||||
pinned by test). The `op_name_is_standard_channel_open_shape` helper
|
||||
and the strip-last derivation are two-way-door implementation details
|
||||
within the one-way wire shape.
|
||||
|
||||
Implemented surface (alkcall 0.7.2): the generalized
|
||||
`derive_alpn_from_op_name` (strip-last), the explicit-field emission
|
||||
(`spec_to_json_pub` + the advertised schema property), and the
|
||||
preference order in `rebuild_spec_for`. Verification gates landed as
|
||||
tests: `channels/tunnel/direct` reconstructs WITH
|
||||
`channel_open = alk/tunnel` (gate 1, both the explicit-string and the
|
||||
skew-case paths); standard boolean ops (`channels/tty/sub`,
|
||||
multi-segment `channels/custom/proto/sub`) round-trip byte-stable
|
||||
(gate 3); the explicit string overrides a colliding derivation; a
|
||||
string without the boolean never marks; `op/register` announced
|
||||
flavor-form specs round-trip with the marker. Gate 2 (the hub-relay
|
||||
round trip) lands with the in-tree relay component (review 008
|
||||
remediation plan Unit 3, amending ADR-042).
|
||||
|
||||
## Amendment (§6 listing enrichment, 2026-09-06)
|
||||
|
||||
|
||||
+224
-22
@@ -263,20 +263,35 @@ pub(crate) fn rebuild_spec_for(
|
||||
);
|
||||
|
||||
// ADR-047 §2: the `channel_open` marker survives discovery
|
||||
// serialization as a boolean. The ALPN is derived from the op name
|
||||
// (`channels/<alpn>/sub` → `alk/<alpn>`). The consumer (e.g. the
|
||||
// hub) branches on the marker to wrap marked ops with relay
|
||||
// machinery (ADR-047 §1, Gap C) instead of the plain forwarding
|
||||
// stub. The marker is on the spec so the consumer can see it
|
||||
// without re-fetching `services/schema`.
|
||||
// serialization as a boolean (plus the explicit `channel_open_alpn`
|
||||
// string for non-derivable op names — ADR-047 amendment, review 008
|
||||
// U-1). The consumer (e.g. the hub) branches on the marker to wrap
|
||||
// marked ops with relay machinery (ADR-047 §1, Gap C) instead of the
|
||||
// plain forwarding stub. The marker is on the spec so the consumer
|
||||
// can see it without re-fetching `services/schema`.
|
||||
let explicit_alpn = schema_json
|
||||
.get("channel_open_alpn")
|
||||
.and_then(|v| v.as_str());
|
||||
if schema_json
|
||||
.get("channel_open")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Some(alpn) = derive_alpn_from_op_name(remote_name) {
|
||||
let alpn = explicit_alpn
|
||||
.map(String::from)
|
||||
.or_else(|| derive_alpn_from_op_name(remote_name));
|
||||
if let Some(alpn) = alpn {
|
||||
spec = spec.with_channel_open(ChannelOpenSpec::new(alpn));
|
||||
}
|
||||
} else if let Some(_alpn) = explicit_alpn {
|
||||
// A `channel_open_alpn` without the boolean marker is not a
|
||||
// channel-open op — the boolean is the dispatch hint; the
|
||||
// string only disambiguates the ALPN for a marked op.
|
||||
tracing::debug!(
|
||||
op = remote_name,
|
||||
"rebuild_spec_for: channel_open_alpn present without the channel_open \
|
||||
boolean marker — ignored (the boolean is the dispatch hint)"
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(publish_schema) = schema_json.get("publish_schema") {
|
||||
@@ -293,20 +308,24 @@ pub(crate) fn rebuild_spec_for(
|
||||
}
|
||||
|
||||
/// Derive the data-plane ALPN from an open-op name
|
||||
/// (`channels/<alpn>/sub` → `alk/<alpn>`). Returns `None` for op
|
||||
/// names that don't match the `channels/<segment>/(sub|pub)` shape —
|
||||
/// the op is not a channel-open op, and the marker (if present) is
|
||||
/// ignored. ADR-047 §"Negative": the path segment is the ALPN with the
|
||||
/// `alk/` prefix stripped; ALPNs without that prefix use their full
|
||||
/// ALPN string as the path segment (rare case). Multi-segment non-`alk/*`
|
||||
/// ALPNs (e.g. `custom/proto`) survive because the `/sub` or `/pub`
|
||||
/// suffix is stripped from `rest` rather than taking only the first
|
||||
/// path segment.
|
||||
/// (`channels/<alpn>/sub` → `alk/<alpn>`). Generalized (ADR-047
|
||||
/// amendment — review 008 U-1): the derivation strips the LAST path
|
||||
/// segment, which covers the standard `…/sub` and `…/pub` shapes
|
||||
/// (byte-identical behavior) AND the flavor form
|
||||
/// (`channels/tunnel/direct` → `alk/tunnel`) — the op-type suffix is
|
||||
/// not special-cased; the boolean marker is the gate (consulted only
|
||||
/// for marked ops in `rebuild_spec_for`). Returns `None` for op names
|
||||
/// without a `channels/` prefix or a trailing segment — the op is not
|
||||
/// a derivable channel-open op, and the marker (if present) without an
|
||||
/// explicit `channel_open_alpn` is ignored. ADR-047 §"Negative": the
|
||||
/// path segment is the ALPN with the `alk/` prefix stripped; ALPNs
|
||||
/// without that prefix use their full ALPN string as the path segment
|
||||
/// (rare case). Multi-segment non-`alk/*` ALPNs (e.g. `custom/proto`)
|
||||
/// survive because the last segment is stripped from `rest` rather
|
||||
/// than taking only the first path segment.
|
||||
fn derive_alpn_from_op_name(op_name: &str) -> Option<String> {
|
||||
let rest = op_name.strip_prefix("channels/")?;
|
||||
let segment = rest
|
||||
.strip_suffix("/sub")
|
||||
.or_else(|| rest.strip_suffix("/pub"))?;
|
||||
let (segment, _flavor) = rest.rsplit_once('/')?;
|
||||
if segment.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -715,6 +734,164 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// --- ADR-047 amendment (review 008 U-1): flavor form + explicit ALPN --
|
||||
|
||||
/// Review 008 U-1 gate 1: a flavor-form marked op reconstructs the
|
||||
/// spec WITH the marker — via the explicit `channel_open_alpn`
|
||||
/// string when the producer supplied it, via the generalized
|
||||
/// derivation otherwise (the deployment-skew case: an old producer
|
||||
/// serving the boolean only).
|
||||
#[test]
|
||||
fn rebuild_spec_flavor_form_reconstructs_marker_via_explicit_alpn() {
|
||||
use crate::registry::discovery::spec_to_json_pub;
|
||||
use crate::registry::spec::ChannelOpenSpec;
|
||||
|
||||
let spec = OperationSpec::new(
|
||||
"channels/tunnel/direct",
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
crate::registry::spec::AccessControl::default(),
|
||||
None,
|
||||
)
|
||||
.with_channel_open(ChannelOpenSpec::new("alk/tunnel"));
|
||||
|
||||
// New producer: the wire payload carries the explicit string.
|
||||
let wire = spec_to_json_pub(&spec);
|
||||
assert_eq!(wire["channel_open"], json!(true));
|
||||
assert_eq!(
|
||||
wire["channel_open_alpn"],
|
||||
json!("alk/tunnel"),
|
||||
"non-standard shape emits the explicit ALPN"
|
||||
);
|
||||
let rebuilt = rebuild_spec_for(&wire, "channels/tunnel/direct", &None).expect("rebuild");
|
||||
let marker = rebuilt.channel_open.expect("marker reconstructed");
|
||||
assert_eq!(marker.alpn, "alk/tunnel");
|
||||
|
||||
// Deployment skew — an OLD alkcall producer serves the boolean
|
||||
// only (no `channel_open_alpn`); the new consumer still
|
||||
// reconstructs the marker via the generalized derivation.
|
||||
let mut old_wire = wire.clone();
|
||||
old_wire
|
||||
.as_object_mut()
|
||||
.expect("object")
|
||||
.remove("channel_open_alpn");
|
||||
let rebuilt =
|
||||
rebuild_spec_for(&old_wire, "channels/tunnel/direct", &None).expect("rebuild");
|
||||
let marker = rebuilt
|
||||
.channel_open
|
||||
.expect("marker reconstructed from the boolean alone (skew case)");
|
||||
assert_eq!(marker.alpn, "alk/tunnel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_spec_explicit_alpn_overrides_derivation() {
|
||||
// The explicit string wins over the name-derived ALPN — the
|
||||
// disambiguator for the residual ambiguity (an ALPN segment
|
||||
// that collides with a flavor name).
|
||||
let mut schema = sample_schema_json("channels/x/direct", "sub");
|
||||
schema["channel_open"] = json!(true);
|
||||
schema["channel_open_alpn"] = json!("alk/x/direct");
|
||||
let spec = rebuild_spec_for(&schema, "channels/x/direct", &None).expect("rebuild");
|
||||
let marker = spec.channel_open.expect("marker");
|
||||
assert_eq!(
|
||||
marker.alpn, "alk/x/direct",
|
||||
"the explicit string wins over the strip-last derivation (alk/x)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_spec_explicit_alpn_without_boolean_is_ignored() {
|
||||
// The boolean marker is the dispatch hint; the string only
|
||||
// disambiguates the ALPN for a marked op. A string alone must
|
||||
// NOT mark a plain op.
|
||||
let mut schema = sample_schema_json("fs/readFile", "query");
|
||||
schema["channel_open_alpn"] = json!("alk/tty");
|
||||
let spec = rebuild_spec_for(&schema, "fs/readFile", &None).expect("rebuild");
|
||||
assert!(
|
||||
spec.channel_open.is_none(),
|
||||
"channel_open_alpn without the boolean never marks an op"
|
||||
);
|
||||
}
|
||||
|
||||
/// Review 008 U-1 gate 3: standard-shape ops round-trip unchanged —
|
||||
/// boolean only, no `channel_open_alpn` key, byte-stable.
|
||||
#[test]
|
||||
fn spec_standard_shape_channel_open_stays_boolean_only() {
|
||||
use crate::registry::discovery::spec_to_json_pub;
|
||||
use crate::registry::spec::ChannelOpenSpec;
|
||||
|
||||
for name in ["channels/tty/sub", "channels/custom/proto/sub"] {
|
||||
let spec = OperationSpec::new(
|
||||
name,
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
crate::registry::spec::AccessControl::default(),
|
||||
None,
|
||||
)
|
||||
.with_channel_open(ChannelOpenSpec::new(if name == "channels/tty/sub" {
|
||||
"alk/tty"
|
||||
} else {
|
||||
"custom/proto"
|
||||
}));
|
||||
let wire = spec_to_json_pub(&spec);
|
||||
assert_eq!(wire["channel_open"], json!(true), "{name}");
|
||||
assert!(
|
||||
wire.get("channel_open_alpn").is_none(),
|
||||
"{name}: standard shape must not emit the explicit key (byte-stable)"
|
||||
);
|
||||
let rebuilt = rebuild_spec_for(&wire, name, &None).expect("rebuild");
|
||||
let marker = rebuilt.channel_open.expect("marker");
|
||||
assert_eq!(
|
||||
marker.alpn.as_ref(),
|
||||
wire["channel_open_alpn"]
|
||||
.as_str()
|
||||
.unwrap_or(if name == "channels/tty/sub" {
|
||||
"alk/tty"
|
||||
} else {
|
||||
"custom/proto"
|
||||
}),
|
||||
"{name} round-trips"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The full flavor-form round trip through the `op/register`
|
||||
/// announced-spec path (the same parser as `from_call`).
|
||||
#[test]
|
||||
fn spec_round_trips_flavor_form_marker() {
|
||||
use crate::registry::discovery::spec_to_json_pub;
|
||||
use crate::registry::spec::ChannelOpenSpec;
|
||||
|
||||
let spec = OperationSpec::new(
|
||||
"channels/tunnel/direct",
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
vec![],
|
||||
crate::registry::spec::AccessControl::default(),
|
||||
None,
|
||||
)
|
||||
.with_channel_open(ChannelOpenSpec::new("alk/tunnel"))
|
||||
.with_description("dynamic-target egress (alktunnels ADR-007)");
|
||||
let wire = spec_to_json_pub(&spec);
|
||||
let rebuilt = rebuild_spec_for(&wire, "channels/tunnel/direct", &None).expect("rebuild");
|
||||
let marker = rebuilt
|
||||
.channel_open
|
||||
.expect("marker survives the round trip");
|
||||
assert_eq!(marker.alpn, "alk/tunnel");
|
||||
assert_eq!(
|
||||
rebuilt.description.as_deref(),
|
||||
Some("dynamic-target egress (alktunnels ADR-007)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_spec_publish_schema_set_when_present() {
|
||||
let publish_schema = json!({
|
||||
@@ -879,12 +1056,37 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_alpn_from_op_name_no_suffix_returns_none() {
|
||||
fn derive_alpn_from_op_name_flavor_form() {
|
||||
// ADR-047 amendment (review 008 U-1): the flavor form — the
|
||||
// gate is the boolean marker at the rebuild level, the
|
||||
// derivation just strips the last segment.
|
||||
assert_eq!(
|
||||
derive_alpn_from_op_name("channels/tunnel/direct"),
|
||||
Some("alk/tunnel".to_string()),
|
||||
"alktunnels ADR-007's direct op (gate 1)"
|
||||
);
|
||||
assert_eq!(
|
||||
derive_alpn_from_op_name("channels/tunnel/forwarded"),
|
||||
Some("alk/tunnel".to_string()),
|
||||
"alktunnels ADR-008's forwarded op"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_alpn_from_op_name_non_op_type_suffix_is_not_special_cased() {
|
||||
// The generalized derivation strips the LAST segment
|
||||
// unconditionally — `sub`/`pub` are not special-cased. The
|
||||
// op-type shape check lives at the rebuild level (the boolean
|
||||
// marker is the gate), so a plain op named `channels/tty/query`
|
||||
// derives here but never reaches the marker path
|
||||
// (`rebuild_spec_channel_open_marker_*` prove the gate).
|
||||
assert_eq!(
|
||||
derive_alpn_from_op_name("channels/tty/query"),
|
||||
None,
|
||||
"op name without /sub or /pub suffix is not a channel-open op"
|
||||
Some("alk/tty".to_string()),
|
||||
"the derivation is shape-blind; the marker is the gate"
|
||||
);
|
||||
assert_eq!(derive_alpn_from_op_name("channels/tty"), None);
|
||||
assert_eq!(derive_alpn_from_op_name("channels/"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+100
-1
@@ -160,6 +160,10 @@ fn operation_spec_schema() -> Value {
|
||||
"type": ["boolean", "null"],
|
||||
"description": "Marker (ADR-047): when true, the op's stream is binary and the channels layer allocates a data channel for it. Absent/null for JSON-stream ops."
|
||||
},
|
||||
"channel_open_alpn": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Explicit data-plane ALPN for a marked op whose name is not the standard channels/<segment>/(sub|pub) shape (ADR-047 amendment — review 008 U-1, the flavor form). The boolean marker remains the dispatch hint; this string only carries the ALPN the name alone cannot disambiguate. Absent for standard-shape marked ops and for JSON-stream ops."
|
||||
},
|
||||
"publish_schema": {
|
||||
"description": "Schema for each published chunk's `input` (Pub ops only, ADR-046 §4). Absent/null for Query/Mutation/Sub ops and for Pub ops with no per-chunk validation. When present, the dispatch path validates each `call.published` event's `payload.input` against this schema before yielding it to the SinkHandler."
|
||||
}
|
||||
@@ -215,6 +219,22 @@ pub(crate) fn spec_to_json(spec: &OperationSpec) -> Value {
|
||||
spec_to_json_pub(spec)
|
||||
}
|
||||
|
||||
/// The standard open-op name shape (`channels/<segment>/(sub|pub)`):
|
||||
/// its ALPN is derivable from the name alone (ADR-047 §Gap F), so the
|
||||
/// wire payload carries the boolean marker only. Any other marked name
|
||||
/// (the flavor form — `channels/tunnel/direct`) is non-derivable and
|
||||
/// serializes the explicit `channel_open_alpn` string alongside the
|
||||
/// boolean (ADR-047 amendment — review 008 U-1).
|
||||
fn op_name_is_standard_channel_open_shape(name: &str) -> bool {
|
||||
let Some(rest) = name.strip_prefix("channels/") else {
|
||||
return false;
|
||||
};
|
||||
let Some((_segment, flavor)) = rest.rsplit_once('/') else {
|
||||
return false;
|
||||
};
|
||||
flavor == "sub" || flavor == "pub"
|
||||
}
|
||||
|
||||
/// Public serialization of an `OperationSpec` into the `services/schema`
|
||||
/// wire shape — the shape `rebuild_spec_for` parses back. Used by the
|
||||
/// `op/register` bootstrap op (review 004 F-05) to carry announced specs
|
||||
@@ -241,8 +261,18 @@ pub fn spec_to_json_pub(spec: &OperationSpec) -> Value {
|
||||
if let Some(description) = &spec.description {
|
||||
json["description"] = json!(description);
|
||||
}
|
||||
if spec.channel_open.is_some() {
|
||||
if let Some(channel_open) = &spec.channel_open {
|
||||
json["channel_open"] = json!(true);
|
||||
// ADR-047 amendment (review 008 U-1): the boolean's round-trip
|
||||
// is derivable only for the standard `channels/<seg>/(sub|pub)`
|
||||
// shapes. For any other marked name (the flavor form —
|
||||
// `channels/tunnel/direct`), the ALPN rides explicitly so the
|
||||
// consumer's `rebuild_spec_for` reconstructs the marker without
|
||||
// guessing. Standard shapes stay byte-identical to the
|
||||
// pre-amendment payload.
|
||||
if !op_name_is_standard_channel_open_shape(&spec.name) {
|
||||
json["channel_open_alpn"] = json!(channel_open.alpn.as_ref());
|
||||
}
|
||||
}
|
||||
if let Some(publish_schema) = &spec.publish_schema {
|
||||
json["publish_schema"] = publish_schema.clone();
|
||||
@@ -1079,6 +1109,75 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// --- ADR-047 amendment (review 008 U-1): explicit channel_open_alpn ----
|
||||
|
||||
#[test]
|
||||
fn spec_to_json_emits_channel_open_alpn_for_flavor_form() {
|
||||
let spec = external_spec("channels/tunnel/direct")
|
||||
.with_channel_open(super::super::spec::ChannelOpenSpec::new("alk/tunnel"));
|
||||
let json_val = spec_to_json(&spec);
|
||||
assert_eq!(json_val.get("channel_open"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
json_val.get("channel_open_alpn"),
|
||||
Some(&json!("alk/tunnel")),
|
||||
"flavor-form marked op emits the explicit ALPN"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spec_to_json_omits_channel_open_alpn_for_standard_shape() {
|
||||
let spec = external_spec("channels/tty/sub")
|
||||
.with_channel_open(super::super::spec::ChannelOpenSpec::new("alk/tty"));
|
||||
let json_val = spec_to_json(&spec);
|
||||
assert_eq!(json_val.get("channel_open"), Some(&json!(true)));
|
||||
assert!(
|
||||
json_val.get("channel_open_alpn").is_none(),
|
||||
"standard shape keeps the boolean-only payload (byte-stable)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spec_to_json_omits_channel_open_alpn_when_marker_absent() {
|
||||
let spec = external_spec("fs/readFile");
|
||||
let json_val = spec_to_json(&spec);
|
||||
assert!(json_val.get("channel_open").is_none());
|
||||
assert!(json_val.get("channel_open_alpn").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_spec_schema_documents_channel_open_alpn_property() {
|
||||
let schema = operation_spec_schema();
|
||||
let props = schema
|
||||
.get("properties")
|
||||
.and_then(|v| v.as_object())
|
||||
.expect("properties object");
|
||||
let prop = props
|
||||
.get("channel_open_alpn")
|
||||
.expect("operation_spec_schema must advertise channel_open_alpn");
|
||||
assert_eq!(
|
||||
prop["type"],
|
||||
json!(["string", "null"]),
|
||||
"optional string property (schema-type widening, additive)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn op_name_is_standard_channel_open_shape_covers_both_flavors() {
|
||||
assert!(op_name_is_standard_channel_open_shape("channels/tty/sub"));
|
||||
assert!(op_name_is_standard_channel_open_shape("channels/tty/pub"));
|
||||
assert!(op_name_is_standard_channel_open_shape(
|
||||
"channels/custom/proto/sub"
|
||||
));
|
||||
assert!(!op_name_is_standard_channel_open_shape(
|
||||
"channels/tunnel/direct"
|
||||
));
|
||||
assert!(!op_name_is_standard_channel_open_shape(
|
||||
"channels/tunnel/forwarded"
|
||||
));
|
||||
assert!(!op_name_is_standard_channel_open_shape("fs/readFile"));
|
||||
assert!(!op_name_is_standard_channel_open_shape("channels/tty"));
|
||||
}
|
||||
|
||||
// --- review 006 E-02: `description` on OperationSpec -------------------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -722,10 +722,39 @@ mod tests {
|
||||
let local_names: Vec<&str> = local
|
||||
.get("operations")
|
||||
.and_then(|v| v.as_array())
|
||||
.expect("local operations array")
|
||||
.expect("consumer operations array")
|
||||
.iter()
|
||||
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
|
||||
.collect();
|
||||
assert!(local_names.contains(&"services/list-peers"));
|
||||
}
|
||||
|
||||
/// ADR-047 amendment (review 008 U-1): a flavor-form marked op
|
||||
/// announced through `op/register` round-trips with the
|
||||
/// `channel_open` marker reconstructed — the explicit
|
||||
/// `channel_open_alpn` string is the carrier for the
|
||||
/// non-derivable name.
|
||||
#[test]
|
||||
fn request_round_trips_flavor_form_channel_open_marker() {
|
||||
use crate::registry::spec::ChannelOpenSpec;
|
||||
|
||||
let request = OpRegisterRequest {
|
||||
spec: announced_spec("channels/tunnel/direct")
|
||||
.with_channel_open(ChannelOpenSpec::new("alk/tunnel")),
|
||||
replace: false,
|
||||
};
|
||||
let json = request.to_json();
|
||||
assert_eq!(json["spec"]["channel_open"], json!(true));
|
||||
assert_eq!(
|
||||
json["spec"]["channel_open_alpn"],
|
||||
json!("alk/tunnel"),
|
||||
"the flavor form rides the explicit ALPN string"
|
||||
);
|
||||
let parsed = OpRegisterRequest::from_json(&json).expect("parse");
|
||||
let marker = parsed
|
||||
.spec
|
||||
.channel_open
|
||||
.expect("marker survives the announce");
|
||||
assert_eq!(marker.alpn, "alk/tunnel");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user