Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions conformance/frames.v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@
},
"wire_hex": "a2647479706566676f7373697065706565727382a366646576696365582011111111111111111111111111111111111111111111111111111111111111116961646472657373657381703230332e302e3131332e353a3434333370736e617073686f742d7365636f6e64731a6ef95380a366646576696365582022222222222222222222222222222222222222222222222222222222222222226961646472657373657382703230332e302e3131332e393a34343333713139382e35312e3130302e323a3434333370736e617073686f742d7365636f6e64731a6ef95381"
},
{
"name": "gossip_v1_peer_advert_with_extension",
"message": {
"type": "gossip",
"peers": [
{
"device": {
"hex": "3333333333333333333333333333333333333333333333333333333333333333"
},
"addresses": [],
"snapshot-seconds": 1861920000,
"presence/status": "idle"
}
]
},
"wire_hex": "a2647479706566676f7373697065706565727381a4666465766963655820333333333333333333333333333333333333333333333333333333333333333369616464726573736573806f70726573656e63652f7374617475736469646c6570736e617073686f742d7365636f6e64731a6efaa500"
},
{
"name": "candidates_v1_host_and_relayed",
"message": {
Expand Down
11 changes: 11 additions & 0 deletions conformance/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,17 @@ const frameVectors: Vector[] = [
},
],
}),
vector("gossip_v1_peer_advert_with_extension", {
type: "gossip",
peers: [
{
device: deviceC,
addresses: [],
"snapshot-seconds": 1861920000,
"presence/status": "idle",
},
],
}),
vector("candidates_v1_host_and_relayed", {
type: "candidates",
candidates: [
Expand Down
61 changes: 51 additions & 10 deletions rust/crates/wire-mesh-wire/src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use minicbor::{Decode, Decoder, Encode, Encoder};
use crate::error::DecodeError;
use crate::identity::{device_id_from, DeviceId};
use crate::strict;
use crate::value::{CanonicalMap, CborValue, CdeKey, CdeMapBuilder};

/// `ping-frame = { type: "ping" }`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
Expand Down Expand Up @@ -91,16 +92,25 @@ pub(crate) fn close_from(d: &mut Decoder<'_>) -> Result<CloseFrame, DecodeError>
Ok(CloseFrame { reason })
}

/// `peer-advert = { device, addresses, snapshot-seconds }`.
/// `peer-advert = { device, addresses, snapshot-seconds, * tstr => any }`.
///
/// CDE key order: `device` (7), `addresses` (10), `snapshot-seconds` (18).
/// CDE key order: `device` (7), `addresses` (10), `snapshot-seconds` (18),
/// with any extension key interleaved by its own encoded-key order (see
/// `spec/CONVENTIONS.md`'s gossip-extension-namespacing convention for the
/// `<domain>/<field>` key shape a well-behaved extension key must use).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerAdvert {
pub device: DeviceId,
/// `host:port` strings.
pub addresses: Vec<String>,
/// Unix-seconds snapshot time.
pub snapshot_seconds: i64,
/// Forward-compatible extension bag (presence status, an accept/refuse
/// policy, or any future gossiped fact) -- an unrecognised key here is
/// exactly as trustworthy as any other gossiped, self-asserted claim,
/// per the verifier obligation `spec/transport.cddl` states directly:
/// ignored, never acted on without understanding it, never an error.
pub extra: CanonicalMap<String, CborValue>,
}

impl Encode<()> for PeerAdvert {
Expand All @@ -109,14 +119,19 @@ impl Encode<()> for PeerAdvert {
e: &mut Encoder<W>,
_ctx: &mut (),
) -> Result<(), minicbor::encode::Error<W::Error>> {
e.map(3)?;
e.str("device")?.encode(self.device)?;
e.str("addresses")?.array(self.addresses.len() as u64)?;
for address in &self.addresses {
e.str(address)?;
let mut builder = CdeMapBuilder::new();
builder.push("device", &self.device);
builder.push("addresses", &self.addresses);
builder.push("snapshot-seconds", &self.snapshot_seconds);
for (key, value) in self.extra.iter() {
let mut value_buf = Vec::new();
let mut value_enc = Encoder::new(&mut value_buf);
value
.encode(&mut value_enc, &mut ())
.unwrap_or_else(|_| unreachable!("Vec<u8> writes are infallible"));
builder.push_raw(key.encoded(), value_buf);
}
e.str("snapshot-seconds")?.i64(self.snapshot_seconds)?;
e.ok()
builder.write(e)
}
}

Expand All @@ -131,6 +146,7 @@ pub(crate) fn peer_advert_from(d: &mut Decoder<'_>) -> Result<PeerAdvert, Decode
let mut device: Option<DeviceId> = None;
let mut addresses: Option<Vec<String>> = None;
let mut snapshot_seconds: Option<i64> = None;
let mut extra = CanonicalMap::new();
while let Some(key) = map.next_key(d)? {
match key {
"device" => strict::set_once(&mut device, device_id_from(d)?)?,
Expand All @@ -143,13 +159,17 @@ pub(crate) fn peer_advert_from(d: &mut Decoder<'_>) -> Result<PeerAdvert, Decode
addresses = Some(list);
}
"snapshot-seconds" => strict::set_once(&mut snapshot_seconds, strict::int_value(d)?)?,
other => return Err(DecodeError::UnknownKey(other.to_owned())),
other => {
let value = CborValue::decode_strict(d)?;
extra.insert(other.to_owned(), value)?;
}
}
}
Ok(PeerAdvert {
device: device.ok_or(DecodeError::MissingField("device"))?,
addresses: addresses.ok_or(DecodeError::MissingField("addresses"))?,
snapshot_seconds: snapshot_seconds.ok_or(DecodeError::MissingField("snapshot-seconds"))?,
extra,
})
}

Expand Down Expand Up @@ -784,6 +804,7 @@ mod tests {
device: DeviceId([1; 32]),
addresses: vec!["203.0.113.5:4433".to_owned()],
snapshot_seconds: 1861833600,
extra: CanonicalMap::new(),
});
let device_at = bytes
.windows(6)
Expand All @@ -800,6 +821,26 @@ mod tests {
assert!(device_at < addresses_at && addresses_at < snapshot_at);
}

#[test]
fn peer_advert_carries_extension_fields() {
// An unrecognised key is accepted into the open `* tstr => any` tail, the same forward-compatible-extension pattern room-notice-claims/token-claims already carry -- a TS peer's sendGossipUpdate(...) extensions must not disconnect a Rust peer.
round_trip(PeerAdvert {
device: DeviceId([3; 32]),
addresses: vec![],
snapshot_seconds: 1861920000,
extra: {
let mut extra = CanonicalMap::new();
extra
.insert(
"presence/status".to_owned(),
CborValue::Text("idle".to_owned()),
)
.expect("insert");
extra
},
});
}

#[test]
fn candidate_kind_literals() {
assert_eq!(CandidateKind::Host.as_str(), "host");
Expand Down
8 changes: 7 additions & 1 deletion spec/CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ When a field's set of possible values is expected to grow as new domains adopt t

Two shipped instances: `capability-scope.kind` (`tokens.cddl`) — "node" and "folder" are Cascade's own scopes, "room"/"org" are agent-comms', "group" is a person's/team's/organisation's own device set, and the comment states plainly that "a future application mints its own kind rather than needing this schema to change." `message-ref.relation` (`room.cddl`) — "reply" and "forward" are the two relations needed today, but the field is an open `tstr` specifically so a future relation (quote, edit-of, supersedes) is an additive value, never a schema change.

Two further instances are anticipated, not yet shipped: a `peer-advert` extension bag (mirroring `token-claims`' own `* tstr => any` tail, proposed so presence status, a peer's own accept/refuse policy, and future gossiped facts share one open extension point rather than each becoming its own bolted-on field) and `core/threshold`'s own `threshold-subject.kind` (the type of content a threshold signature covers) — named here so whoever builds either reaches for an open field from the start rather than shipping a closed enum and having to widen it later.
One further instance is shipped: `peer-advert`'s own extension bag (mirroring `token-claims`' own `* tstr => any` tail; `transport.cddl`), letting presence status, a peer's own accept/refuse policy, and future gossiped facts share one open extension point rather than each becoming its own bolted-on field — see the gossip-extension-namespacing convention below for the key-naming rule that keeps two independent applications from colliding on it. One further instance is anticipated, not yet shipped: `core/threshold`'s own `threshold-subject.kind` (the type of content a threshold signature covers) — named here so whoever builds it reaches for an open field from the start rather than shipping a closed enum and having to widen it later.

## Namespacing keys in a shared gossip extension tail

`peer-advert`'s extension tail (`transport.cddl`) is a single flat `* tstr => any` map shared by every application advertising presence, policy, or any other fact over a session — nothing in the shape itself stops two independent domains from choosing the same field name (both wanting a key called `status`, say) and silently overwriting or misreading each other's value.

**Convention**: a gossip extension key MUST be domain-qualified as `<domain>/<field>` (e.g. `presence/status`, not bare `status`), and MUST NOT repeat one of `peer-advert`'s own typed field names (`device`, `addresses`, `snapshot-seconds`) — a well-behaved sender rejects an attempt to advertise under either shape rather than let it collide with something else's meaning or shadow a real field. `wire-mesh-core`'s `sendGossipUpdate` enforces both rules at the call site, not only in prose: a caller passing a reserved or non-qualified key gets a thrown error immediately, rather than a frame that silently corrupts or ambiguously shares a key.

**Convention**: before adding a closed enum for any field that names a *kind* of something (a scope kind, a relation, a content type, a subject type), ask whether a future domain might reasonably need a value this spec doesn't anticipate. If yes — which is the common case for anything describing "what kind of X is this" rather than a truly fixed, small, protocol-level choice — use an open `tstr` (optionally pattern-constrained) instead, and pair it with the verifier obligation above: an unrecognised value must be refused, never guessed at.
23 changes: 23 additions & 0 deletions spec/protocol.cddl
Original file line number Diff line number Diff line change
Expand Up @@ -849,10 +849,33 @@ token-claims = {
ping-frame = { type: "ping" }
close-frame = { type: "close", ? reason: tstr }

; The extension tail mirrors token-claims' own `* tstr => any` (tokens.cddl)
; and is the generalised gossip-extension point named in the agent-comms
; migration design's own P4 refinement: a node's presence status, its own
; accept/refuse policy for a claim class (e.g. "does not accept messages
; carrying a valid-until claim"), and any future domain's own gossiped fact
; all ride this one open tail, added once rather than as a series of
; separately-named fields each time a new fact needs advertising. A field
; here is exactly as trustworthy as any other gossiped, self-asserted claim
; in this spec (peer-advert carries no signature of its own) — a reader
; treats an unrecognised key the same verifier-obligation way an unrecognised
; value in an open discriminator is treated elsewhere in this spec: ignored,
; never acted on without understanding it, never treated as an error.
;
; A key here MUST be domain-qualified as "<domain>/<field>" (e.g.
; "presence/status", not bare "status") — see spec/CONVENTIONS.md's
; gossip-extension-namespacing convention. This is what lets two independent
; applications advertising over the same session coexist without one's
; extension silently shadowing the other's identically-named field. A key
; here MUST also never repeat one of this map's own typed field names
; (device/addresses/snapshot-seconds) — an implementation MUST reject an
; attempt to advertise under one of those names rather than let it shadow
; the real field.
peer-advert = {
device: device-id,
addresses: [* tstr], ; "host:port" strings
snapshot-seconds: int, ; Unix-seconds snapshot time
* tstr => any,
}
gossip-frame = { type: "gossip", peers: [* peer-advert] }

Expand Down
23 changes: 23 additions & 0 deletions spec/transport.cddl
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,33 @@
ping-frame = { type: "ping" }
close-frame = { type: "close", ? reason: tstr }

; The extension tail mirrors token-claims' own `* tstr => any` (tokens.cddl)
; and is the generalised gossip-extension point named in the agent-comms
; migration design's own P4 refinement: a node's presence status, its own
; accept/refuse policy for a claim class (e.g. "does not accept messages
; carrying a valid-until claim"), and any future domain's own gossiped fact
; all ride this one open tail, added once rather than as a series of
; separately-named fields each time a new fact needs advertising. A field
; here is exactly as trustworthy as any other gossiped, self-asserted claim
; in this spec (peer-advert carries no signature of its own) — a reader
; treats an unrecognised key the same verifier-obligation way an unrecognised
; value in an open discriminator is treated elsewhere in this spec: ignored,
; never acted on without understanding it, never treated as an error.
;
; A key here MUST be domain-qualified as "<domain>/<field>" (e.g.
; "presence/status", not bare "status") — see spec/CONVENTIONS.md's
; gossip-extension-namespacing convention. This is what lets two independent
; applications advertising over the same session coexist without one's
; extension silently shadowing the other's identically-named field. A key
; here MUST also never repeat one of this map's own typed field names
; (device/addresses/snapshot-seconds) — an implementation MUST reject an
; attempt to advertise under one of those names rather than let it shadow
; the real field.
peer-advert = {
device: device-id,
addresses: [* tstr], ; "host:port" strings
snapshot-seconds: int, ; Unix-seconds snapshot time
* tstr => any,
Comment thread
Mearman marked this conversation as resolved.
Comment thread
Mearman marked this conversation as resolved.
Comment thread
Mearman marked this conversation as resolved.
}
gossip-frame = { type: "gossip", peers: [* peer-advert] }

Expand Down
Loading