Skip to main content

acdp_types/
change.rs

1//! Retraction recall wire types (ACDP 0.5.0 Draft, RFC-ACDP-0018).
2//!
3//! - [`Change`] — one `acdp-change/1` entry of a registry's `GET /changes`
4//!   feed (§4). Its evidence is a per-kind enum ([`ChangeEvidence`]), so
5//!   a `lifecycle` entry carrying a `registry_receipt` (or any other
6//!   cross-kind mix) cannot be built or parsed.
7//! - [`ChangesResponse`] — the `GET /changes` response body (§5.2).
8//! - [`WatchFilter`] — the stateless §6 filter, with the registry-side
9//!   matching rule ([`WatchFilter::matches`]).
10//! - [`SubscriptionRequest`], [`Subscription`], [`ChangeHint`] — the §8
11//!   change-hint webhook objects, plus [`sign_hint`] /
12//!   [`verify_hint_signature`] for the `ACDP-Signature` header.
13//!
14//! A change entry is **evidence, not a verdict** (§4): parsing one proves
15//! only its shape. The signed objects it carries are kept as the raw wire
16//! JSON, because their signatures cover the bytes as served; consumers
17//! verify them per §7 (`acdp-client`'s `recall` module) before changing
18//! any reliance decision.
19
20use crate::lifecycle::LifecycleEvent;
21use crate::receipt::{is_canonical_ms_utc, RegistryReceipt};
22use crate::serde_helpers::de_present;
23use crate::EventReceipt;
24use acdp_primitives::error::AcdpError;
25use acdp_primitives::primitives::{is_valid_dns_authority, AgentDid, CtxId, LineageId};
26use chrono::{DateTime, Utc};
27use hmac::{Hmac, KeyInit, Mac};
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30use sha2::Sha256;
31
32/// The `change_version` literal of a change entry.
33pub const CHANGE_VERSION: &str = "acdp-change/1";
34/// The `hint_version` literal of a change hint.
35pub const CHANGE_HINT_VERSION: &str = "acdp-change-hint/1";
36/// The header carrying a change hint's HMAC (RFC-ACDP-0018 §8.2).
37pub const SIGNATURE_HEADER: &str = "ACDP-Signature";
38/// A receiver rejects a hint whose `t` is further than this from its
39/// clock, in seconds (RFC-ACDP-0018 §8.2).
40pub const HINT_MAX_SKEW_SECONDS: i64 = 300;
41/// Feed cursors are opaque strings of 1 to this many characters.
42pub const MAX_CURSOR_LEN: usize = 2048;
43/// The minimum `limits.change_retention_seconds` (30 days, §5.4).
44pub const MIN_CHANGE_RETENTION_SECONDS: u64 = 2_592_000;
45
46fn bad(msg: impl Into<String>) -> AcdpError {
47    AcdpError::SchemaViolation(msg.into())
48}
49
50// ── Change kinds and evidence ────────────────────────────────────────────────
51
52/// What a change entry records (RFC-ACDP-0018 §3). Closed in
53/// `acdp-change/1`: a new kind needs `acdp-change/2`.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum ChangeKind {
57    /// A lifecycle event was appended to the affected context.
58    Lifecycle,
59    /// A newer version superseding the affected context was accepted.
60    Superseded,
61    /// A `key-revocation` context (the affected context) was accepted.
62    KeyRevocation,
63}
64
65impl ChangeKind {
66    /// Every kind, in wire order.
67    pub const ALL: [ChangeKind; 3] = [
68        ChangeKind::Lifecycle,
69        ChangeKind::Superseded,
70        ChangeKind::KeyRevocation,
71    ];
72
73    /// The wire form: `lifecycle`, `superseded` or `key_revocation`.
74    pub fn as_str(self) -> &'static str {
75        match self {
76            ChangeKind::Lifecycle => "lifecycle",
77            ChangeKind::Superseded => "superseded",
78            ChangeKind::KeyRevocation => "key_revocation",
79        }
80    }
81
82    /// Parse the wire form. An unknown kind is a
83    /// [`AcdpError::SchemaViolation`], as §5.1 requires of a registry.
84    pub fn parse(s: &str) -> Result<Self, AcdpError> {
85        Self::ALL
86            .into_iter()
87            .find(|k| k.as_str() == s)
88            .ok_or_else(|| bad(format!("unknown change kind '{s}' (RFC-ACDP-0018 §3)")))
89    }
90}
91
92impl std::fmt::Display for ChangeKind {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.write_str(self.as_str())
95    }
96}
97
98/// A change entry's evidence, by kind (RFC-ACDP-0018 §4). The signed
99/// objects are the raw wire JSON exactly as served.
100#[derive(Debug, Clone, PartialEq)]
101pub enum ChangeEvidence {
102    /// `kind: lifecycle`.
103    Lifecycle {
104        /// The stored lifecycle event, byte-for-byte as served in
105        /// `lifecycle_events`.
106        event: Value,
107        /// Its `acdp-event-receipt/1` (RFC-ACDP-0017 §10).
108        event_receipt: Value,
109    },
110    /// `kind: superseded`.
111    Superseded {
112        /// The new version's `ctx_id`.
113        superseded_by: CtxId,
114        /// The new version's registry receipt (RFC-ACDP-0010).
115        registry_receipt: Value,
116    },
117    /// `kind: key_revocation`.
118    KeyRevocation {
119        /// The revocation context's registry receipt.
120        registry_receipt: Value,
121    },
122}
123
124impl ChangeEvidence {
125    /// The kind this evidence belongs to.
126    pub fn kind(&self) -> ChangeKind {
127        match self {
128            ChangeEvidence::Lifecycle { .. } => ChangeKind::Lifecycle,
129            ChangeEvidence::Superseded { .. } => ChangeKind::Superseded,
130            ChangeEvidence::KeyRevocation { .. } => ChangeKind::KeyRevocation,
131        }
132    }
133}
134
135// ── The change entry ─────────────────────────────────────────────────────────
136
137/// One `acdp-change/1` entry (RFC-ACDP-0018 §4), CLOSED.
138///
139/// Built only through [`Self::from_value`] / `Deserialize` (which enforce
140/// the schema, including each carried object's own closed schema) or the
141/// registry-side constructors [`Self::lifecycle`], [`Self::superseded`]
142/// and [`Self::key_revocation`] (which derive `recorded_at` and the ids
143/// from the evidence, so they cannot disagree with it).
144///
145/// Parsing does not verify anything: see [`Self::check_consistency`] for
146/// the pure cross-checks, and `acdp-client`'s `verify_change` for §7.
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148#[serde(try_from = "ChangeWire", into = "ChangeWire")]
149pub struct Change {
150    seq: u64,
151    ctx_id: CtxId,
152    lineage_id: LineageId,
153    agent_id: AgentDid,
154    recorded_at: DateTime<Utc>,
155    evidence: ChangeEvidence,
156}
157
158impl Change {
159    /// Parse a change entry from wire JSON, enforcing the closed schema.
160    /// Failures are [`AcdpError::SchemaViolation`].
161    pub fn from_value(value: &Value) -> Result<Self, AcdpError> {
162        Self::deserialize(value).map_err(|e| bad(format!("change entry does not parse: {e}")))
163    }
164
165    /// A `lifecycle` entry for the event at `event_receipt.index` of the
166    /// receipt's context. `raw_event` is the stored event, verbatim;
167    /// `recorded_at` is the receipt's `accepted_at`.
168    pub fn lifecycle(
169        seq: u64,
170        lineage_id: LineageId,
171        agent_id: AgentDid,
172        raw_event: Value,
173        event_receipt: &EventReceipt,
174    ) -> Result<Self, AcdpError> {
175        let change = Self {
176            seq,
177            ctx_id: event_receipt.ctx_id.clone(),
178            lineage_id,
179            agent_id,
180            recorded_at: event_receipt.accepted_at,
181            evidence: ChangeEvidence::Lifecycle {
182                event: raw_event,
183                event_receipt: serde_json::to_value(event_receipt)?,
184            },
185        };
186        change.validate()?;
187        Ok(change)
188    }
189
190    /// A `superseded` entry: `ctx_id` was superseded by the version
191    /// `registry_receipt` was minted for. The lineage is the receipt's;
192    /// `agent_id` is the superseded context's producer.
193    pub fn superseded(
194        seq: u64,
195        ctx_id: CtxId,
196        agent_id: AgentDid,
197        registry_receipt: &RegistryReceipt,
198    ) -> Result<Self, AcdpError> {
199        let change = Self {
200            seq,
201            ctx_id,
202            lineage_id: registry_receipt.lineage_id.clone(),
203            agent_id,
204            recorded_at: registry_receipt.created_at,
205            evidence: ChangeEvidence::Superseded {
206                superseded_by: registry_receipt.ctx_id.clone(),
207                registry_receipt: serde_json::to_value(registry_receipt)?,
208            },
209        };
210        change.validate()?;
211        Ok(change)
212    }
213
214    /// A `key_revocation` entry for the revocation context
215    /// `registry_receipt` was minted for. `agent_id` is the revoked key's
216    /// controller: `metadata.revoked_key_controller` when present, else
217    /// the revocation body's `agent_id` (RFC-ACDP-0018 §4).
218    pub fn key_revocation(
219        seq: u64,
220        agent_id: AgentDid,
221        registry_receipt: &RegistryReceipt,
222    ) -> Result<Self, AcdpError> {
223        let change = Self {
224            seq,
225            ctx_id: registry_receipt.ctx_id.clone(),
226            lineage_id: registry_receipt.lineage_id.clone(),
227            agent_id,
228            recorded_at: registry_receipt.created_at,
229            evidence: ChangeEvidence::KeyRevocation {
230                registry_receipt: serde_json::to_value(registry_receipt)?,
231            },
232        };
233        change.validate()?;
234        Ok(change)
235    }
236
237    /// The entry's sequence number (§5.3).
238    pub fn seq(&self) -> u64 {
239        self.seq
240    }
241    /// The entry's kind.
242    pub fn kind(&self) -> ChangeKind {
243        self.evidence.kind()
244    }
245    /// The affected context.
246    pub fn ctx_id(&self) -> &CtxId {
247        &self.ctx_id
248    }
249    /// The affected context's lineage.
250    pub fn lineage_id(&self) -> &LineageId {
251        &self.lineage_id
252    }
253    /// The producer whose reliance the change touches (§4).
254    pub fn agent_id(&self) -> &AgentDid {
255        &self.agent_id
256    }
257    /// The registry's clock at commit.
258    pub fn recorded_at(&self) -> DateTime<Utc> {
259        self.recorded_at
260    }
261    /// The evidence, by kind.
262    pub fn evidence(&self) -> &ChangeEvidence {
263        &self.evidence
264    }
265
266    /// The schema invariants serde does not enforce: the ids' grammars,
267    /// and each carried object's own closed schema.
268    fn validate(&self) -> Result<(), AcdpError> {
269        CtxId::parse(self.ctx_id.as_str())?;
270        LineageId::parse(self.lineage_id.as_str())?;
271        AgentDid::parse(self.agent_id.as_str())?;
272        match &self.evidence {
273            ChangeEvidence::Lifecycle {
274                event,
275                event_receipt,
276            } => {
277                LifecycleEvent::from_value(event)?;
278                EventReceipt::from_value(event_receipt)?;
279            }
280            ChangeEvidence::Superseded {
281                superseded_by,
282                registry_receipt,
283            } => {
284                CtxId::parse(superseded_by.as_str())?;
285                RegistryReceipt::from_value(registry_receipt)?;
286            }
287            ChangeEvidence::KeyRevocation { registry_receipt } => {
288                RegistryReceipt::from_value(registry_receipt)?;
289            }
290        }
291        Ok(())
292    }
293
294    /// The pure cross-checks between an entry's envelope and its
295    /// evidence (RFC-ACDP-0018 §4), run before any network round-trip:
296    ///
297    /// - `lifecycle`: the event's and the receipt's `ctx_id` are the
298    ///   entry's; `recorded_at` equals `event_receipt.accepted_at`.
299    /// - `superseded`: the receipt is for `superseded_by` (not the
300    ///   entry's own context) in the entry's lineage; `recorded_at`
301    ///   equals `registry_receipt.created_at`.
302    /// - `key_revocation`: the receipt is for the entry's context and
303    ///   lineage; `recorded_at` equals `registry_receipt.created_at`.
304    ///
305    /// Signatures and the receipt's binding to the event are NOT checked
306    /// here. Failures are [`AcdpError::SchemaViolation`].
307    pub fn check_consistency(&self) -> Result<(), AcdpError> {
308        let recorded = |at: DateTime<Utc>, what: &str| {
309            if at == self.recorded_at {
310                Ok(())
311            } else {
312                Err(bad(format!(
313                    "change {} recorded_at {} ≠ {what} {} (RFC-ACDP-0018 §4)",
314                    self.seq,
315                    fmt_ms(self.recorded_at),
316                    fmt_ms(at)
317                )))
318            }
319        };
320        match &self.evidence {
321            ChangeEvidence::Lifecycle {
322                event,
323                event_receipt,
324            } => {
325                let e = LifecycleEvent::from_value(event)?;
326                let r = EventReceipt::from_value(event_receipt)?;
327                if e.ctx_id != self.ctx_id || r.ctx_id != self.ctx_id {
328                    return Err(bad(format!(
329                        "change {} names ctx_id {} but its event or event receipt names another \
330                         context",
331                        self.seq, self.ctx_id
332                    )));
333                }
334                recorded(r.accepted_at, "event_receipt.accepted_at")
335            }
336            ChangeEvidence::Superseded {
337                superseded_by,
338                registry_receipt,
339            } => {
340                let r = RegistryReceipt::from_value(registry_receipt)?;
341                if superseded_by == &self.ctx_id {
342                    return Err(bad(format!(
343                        "change {}: a context cannot supersede itself",
344                        self.seq
345                    )));
346                }
347                if &r.ctx_id != superseded_by || r.lineage_id != self.lineage_id {
348                    return Err(bad(format!(
349                        "change {}: registry_receipt is not for superseded_by {superseded_by} in \
350                         lineage {}",
351                        self.seq, self.lineage_id
352                    )));
353                }
354                recorded(r.created_at, "registry_receipt.created_at")
355            }
356            ChangeEvidence::KeyRevocation { registry_receipt } => {
357                let r = RegistryReceipt::from_value(registry_receipt)?;
358                if r.ctx_id != self.ctx_id || r.lineage_id != self.lineage_id {
359                    return Err(bad(format!(
360                        "change {}: registry_receipt is not for the revocation context {} in \
361                         lineage {}",
362                        self.seq, self.ctx_id, self.lineage_id
363                    )));
364                }
365                recorded(r.created_at, "registry_receipt.created_at")
366            }
367        }
368    }
369}
370
371fn fmt_ms(t: DateTime<Utc>) -> String {
372    t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
373}
374
375fn parse_ms(raw: &str, what: &str) -> Result<DateTime<Utc>, AcdpError> {
376    if !is_canonical_ms_utc(raw) {
377        return Err(bad(format!(
378            "{what} '{raw}' is not canonical millisecond-precision RFC 3339 UTC"
379        )));
380    }
381    Ok(DateTime::parse_from_rfc3339(raw)
382        .expect("is_canonical_ms_utc parsed it")
383        .with_timezone(&Utc))
384}
385
386/// The flat wire form of [`Change`].
387#[derive(Serialize, Deserialize)]
388#[serde(deny_unknown_fields)]
389struct ChangeWire {
390    change_version: String,
391    seq: u64,
392    kind: ChangeKind,
393    ctx_id: CtxId,
394    lineage_id: LineageId,
395    agent_id: AgentDid,
396    recorded_at: String,
397    #[serde(
398        default,
399        skip_serializing_if = "Option::is_none",
400        deserialize_with = "de_present"
401    )]
402    event: Option<Value>,
403    #[serde(
404        default,
405        skip_serializing_if = "Option::is_none",
406        deserialize_with = "de_present"
407    )]
408    event_receipt: Option<Value>,
409    #[serde(
410        default,
411        skip_serializing_if = "Option::is_none",
412        deserialize_with = "de_present"
413    )]
414    superseded_by: Option<CtxId>,
415    #[serde(
416        default,
417        skip_serializing_if = "Option::is_none",
418        deserialize_with = "de_present"
419    )]
420    registry_receipt: Option<Value>,
421}
422
423impl TryFrom<ChangeWire> for Change {
424    type Error = AcdpError;
425
426    fn try_from(w: ChangeWire) -> Result<Self, AcdpError> {
427        if w.change_version != CHANGE_VERSION {
428            return Err(bad(format!(
429                "change_version '{}' ≠ '{CHANGE_VERSION}'",
430                w.change_version
431            )));
432        }
433        let recorded_at = parse_ms(&w.recorded_at, "recorded_at")?;
434        let mismatch = |extra: &str| {
435            bad(format!(
436                "a '{}' change entry must not carry '{extra}' (RFC-ACDP-0018 §4)",
437                w.kind
438            ))
439        };
440        let missing = |member: &str| {
441            bad(format!(
442                "a '{}' change entry requires '{member}' (RFC-ACDP-0018 §4)",
443                w.kind
444            ))
445        };
446        let evidence = match w.kind {
447            ChangeKind::Lifecycle => {
448                if w.superseded_by.is_some() {
449                    return Err(mismatch("superseded_by"));
450                }
451                if w.registry_receipt.is_some() {
452                    return Err(mismatch("registry_receipt"));
453                }
454                ChangeEvidence::Lifecycle {
455                    event: w.event.ok_or_else(|| missing("event"))?,
456                    event_receipt: w.event_receipt.ok_or_else(|| missing("event_receipt"))?,
457                }
458            }
459            ChangeKind::Superseded => {
460                if w.event.is_some() {
461                    return Err(mismatch("event"));
462                }
463                if w.event_receipt.is_some() {
464                    return Err(mismatch("event_receipt"));
465                }
466                ChangeEvidence::Superseded {
467                    superseded_by: w.superseded_by.ok_or_else(|| missing("superseded_by"))?,
468                    registry_receipt: w
469                        .registry_receipt
470                        .ok_or_else(|| missing("registry_receipt"))?,
471                }
472            }
473            ChangeKind::KeyRevocation => {
474                for (present, name) in [
475                    (w.event.is_some(), "event"),
476                    (w.event_receipt.is_some(), "event_receipt"),
477                    (w.superseded_by.is_some(), "superseded_by"),
478                ] {
479                    if present {
480                        return Err(mismatch(name));
481                    }
482                }
483                ChangeEvidence::KeyRevocation {
484                    registry_receipt: w
485                        .registry_receipt
486                        .ok_or_else(|| missing("registry_receipt"))?,
487                }
488            }
489        };
490        let change = Change {
491            seq: w.seq,
492            ctx_id: w.ctx_id,
493            lineage_id: w.lineage_id,
494            agent_id: w.agent_id,
495            recorded_at,
496            evidence,
497        };
498        change.validate()?;
499        Ok(change)
500    }
501}
502
503impl From<Change> for ChangeWire {
504    fn from(c: Change) -> Self {
505        let mut w = ChangeWire {
506            change_version: CHANGE_VERSION.into(),
507            seq: c.seq,
508            kind: c.evidence.kind(),
509            ctx_id: c.ctx_id,
510            lineage_id: c.lineage_id,
511            agent_id: c.agent_id,
512            recorded_at: fmt_ms(c.recorded_at),
513            event: None,
514            event_receipt: None,
515            superseded_by: None,
516            registry_receipt: None,
517        };
518        match c.evidence {
519            ChangeEvidence::Lifecycle {
520                event,
521                event_receipt,
522            } => {
523                w.event = Some(event);
524                w.event_receipt = Some(event_receipt);
525            }
526            ChangeEvidence::Superseded {
527                superseded_by,
528                registry_receipt,
529            } => {
530                w.superseded_by = Some(superseded_by);
531                w.registry_receipt = Some(registry_receipt);
532            }
533            ChangeEvidence::KeyRevocation { registry_receipt } => {
534                w.registry_receipt = Some(registry_receipt);
535            }
536        }
537        w
538    }
539}
540
541// ── The feed response ────────────────────────────────────────────────────────
542
543/// The `GET /changes` response body (RFC-ACDP-0018 §5.2), CLOSED.
544///
545/// `next_cursor` is always present: the feed is a tail, so a consumer at
546/// the head keeps polling with it. `has_more` means entries past
547/// `next_cursor` were already committed; an empty `changes` with
548/// `has_more: true` is not the end of the feed.
549#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
550#[serde(deny_unknown_fields)]
551pub struct ChangesResponse {
552    /// Matching, visible entries after `since`, ascending `seq`.
553    pub changes: Vec<Change>,
554    /// Opaque, durable cursor naming the position after the last entry
555    /// scanned (§5.4).
556    pub next_cursor: String,
557    /// Entries past `next_cursor` were committed when the response was
558    /// built; request again at once.
559    pub has_more: bool,
560}
561
562impl ChangesResponse {
563    /// Parse a response, enforcing the closed schema, the cursor length
564    /// bounds and strictly ascending `seq`. Failures are
565    /// [`AcdpError::SchemaViolation`].
566    pub fn from_value(value: &Value) -> Result<Self, AcdpError> {
567        let r = Self::deserialize(value)
568            .map_err(|e| bad(format!("changes response does not parse: {e}")))?;
569        r.validate()?;
570        Ok(r)
571    }
572
573    /// The invariants serde does not enforce.
574    pub fn validate(&self) -> Result<(), AcdpError> {
575        check_cursor(&self.next_cursor, "next_cursor")?;
576        if self.changes.windows(2).any(|w| w[0].seq >= w[1].seq) {
577            return Err(bad(
578                "changes are not in strictly ascending seq order (RFC-ACDP-0018 §5.2, §5.3)",
579            ));
580        }
581        Ok(())
582    }
583}
584
585fn check_cursor(c: &str, what: &str) -> Result<(), AcdpError> {
586    if c.is_empty() || c.chars().count() > MAX_CURSOR_LEN {
587        return Err(bad(format!(
588            "{what} must be 1 to {MAX_CURSOR_LEN} characters (RFC-ACDP-0018 §5.4)"
589        )));
590    }
591    Ok(())
592}
593
594// ── Watch filters ────────────────────────────────────────────────────────────
595
596/// A §6 watch filter: the `ctx_id`, `lineage_id`, `agent_id` and `kind`
597/// values of a `GET /changes` request or a subscription.
598///
599/// Values within a member are OR'd; `ctx_id`, `lineage_id` and
600/// `agent_id` are OR'd with each other; `kind` is AND'd with the result.
601/// With no id value at all, every visible entry matches.
602#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
603#[serde(deny_unknown_fields)]
604pub struct WatchFilter {
605    /// Watched contexts.
606    #[serde(default, skip_serializing_if = "Vec::is_empty")]
607    pub ctx_id: Vec<CtxId>,
608    /// Watched lineages.
609    #[serde(default, skip_serializing_if = "Vec::is_empty")]
610    pub lineage_id: Vec<LineageId>,
611    /// Watched producers.
612    #[serde(default, skip_serializing_if = "Vec::is_empty")]
613    pub agent_id: Vec<AgentDid>,
614    /// Restrict to these kinds; empty means every kind.
615    #[serde(default, skip_serializing_if = "Vec::is_empty")]
616    pub kind: Vec<ChangeKind>,
617}
618
619impl WatchFilter {
620    /// The empty filter: every visible entry.
621    pub fn new() -> Self {
622        Self::default()
623    }
624    /// Also watch context `c`.
625    pub fn ctx_id(mut self, c: CtxId) -> Self {
626        self.ctx_id.push(c);
627        self
628    }
629    /// Also watch lineage `l`.
630    pub fn lineage_id(mut self, l: LineageId) -> Self {
631        self.lineage_id.push(l);
632        self
633    }
634    /// Also watch producer `a`.
635    pub fn agent_id(mut self, a: AgentDid) -> Self {
636        self.agent_id.push(a);
637        self
638    }
639    /// Also accept kind `k`.
640    pub fn kind(mut self, k: ChangeKind) -> Self {
641        self.kind.push(k);
642        self
643    }
644
645    /// True when no member has a value.
646    pub fn is_empty(&self) -> bool {
647        self.total_values() == 0
648    }
649
650    /// The total number of values, which `limits.max_change_filters`
651    /// bounds (§5.1).
652    pub fn total_values(&self) -> usize {
653        self.ctx_id.len() + self.lineage_id.len() + self.agent_id.len() + self.kind.len()
654    }
655
656    /// Check the ids' grammars and, when given, the
657    /// `limits.max_change_filters` bound. Failures are
658    /// [`AcdpError::SchemaViolation`], as §5.1 requires of a registry.
659    pub fn validate(&self, max_change_filters: Option<u64>) -> Result<(), AcdpError> {
660        for c in &self.ctx_id {
661            CtxId::parse(c.as_str())?;
662        }
663        for l in &self.lineage_id {
664            LineageId::parse(l.as_str())?;
665        }
666        for a in &self.agent_id {
667            AgentDid::parse(a.as_str())?;
668        }
669        if let Some(max) = max_change_filters {
670            if self.total_values() as u64 > max {
671                return Err(bad(format!(
672                    "{} filter values exceed limits.max_change_filters {max} (RFC-ACDP-0018 §5.1)",
673                    self.total_values()
674                )));
675            }
676        }
677        Ok(())
678    }
679
680    /// The stricter form a subscription filter takes (§8.1): at least one
681    /// value, and no member repeating a value.
682    pub fn validate_for_subscription(
683        &self,
684        max_change_filters: Option<u64>,
685    ) -> Result<(), AcdpError> {
686        self.validate(max_change_filters)?;
687        if self.is_empty() {
688            return Err(bad("a subscription filter needs at least one value"));
689        }
690        fn unique<T: Ord>(v: &[T]) -> bool {
691            let mut seen = std::collections::BTreeSet::new();
692            v.iter().all(|x| seen.insert(x))
693        }
694        if !unique(&self.ctx_id.iter().map(CtxId::as_str).collect::<Vec<_>>())
695            || !unique(
696                &self
697                    .lineage_id
698                    .iter()
699                    .map(LineageId::as_str)
700                    .collect::<Vec<_>>(),
701            )
702            || !unique(
703                &self
704                    .agent_id
705                    .iter()
706                    .map(AgentDid::as_str)
707                    .collect::<Vec<_>>(),
708            )
709            || !unique(&self.kind)
710        {
711            return Err(bad("a subscription filter member repeats a value"));
712        }
713        Ok(())
714    }
715
716    /// The repeatable `GET /changes` query parameters (§5.1), in member
717    /// order.
718    pub fn query_pairs(&self) -> Vec<(&'static str, String)> {
719        let mut q = Vec::with_capacity(self.total_values());
720        q.extend(
721            self.ctx_id
722                .iter()
723                .map(|c| ("ctx_id", c.as_str().to_owned())),
724        );
725        q.extend(
726            self.lineage_id
727                .iter()
728                .map(|l| ("lineage_id", l.as_str().to_owned())),
729        );
730        q.extend(
731            self.agent_id
732                .iter()
733                .map(|a| ("agent_id", a.as_str().to_owned())),
734        );
735        q.extend(self.kind.iter().map(|k| ("kind", k.as_str().to_owned())));
736        q
737    }
738
739    /// Whether `change` matches (§6). `producer_of` returns the
740    /// `agent_id` of a context the registry holds: a `ctx_id=X` watch
741    /// also matches a `key_revocation` entry for X's producer.
742    pub fn matches(
743        &self,
744        change: &Change,
745        producer_of: impl Fn(&CtxId) -> Option<AgentDid>,
746    ) -> bool {
747        if !self.kind.is_empty() && !self.kind.contains(&change.kind()) {
748            return false;
749        }
750        if self.ctx_id.is_empty() && self.lineage_id.is_empty() && self.agent_id.is_empty() {
751            return true;
752        }
753        self.ctx_id.contains(&change.ctx_id)
754            || self.lineage_id.contains(&change.lineage_id)
755            || self.agent_id.contains(&change.agent_id)
756            || (change.kind() == ChangeKind::KeyRevocation
757                && self
758                    .ctx_id
759                    .iter()
760                    .any(|c| producer_of(c).as_ref() == Some(&change.agent_id)))
761    }
762}
763
764/// Reject a filter member given as `[]` on the wire (`minItems: 1`).
765fn check_filter_arrays(filter: Option<&Value>) -> Result<(), AcdpError> {
766    if let Some(obj) = filter.and_then(Value::as_object) {
767        for (k, v) in obj {
768            if v.as_array().is_some_and(Vec::is_empty) {
769                return Err(bad(format!("filter.{k} is empty (minItems 1)")));
770            }
771        }
772    }
773    Ok(())
774}
775
776fn check_callback_url(url: &str) -> Result<(), AcdpError> {
777    let host = url.strip_prefix("https://").map(|r| {
778        r.split(['/', '?', '#'])
779            .next()
780            .unwrap_or("")
781            .rsplit('@')
782            .next()
783            .unwrap_or("")
784    });
785    if url.len() > 2048
786        || url.chars().any(|c| c.is_whitespace() || c.is_control())
787        || host.is_none_or(str::is_empty)
788    {
789        return Err(bad(format!(
790            "callback_url '{url}' is not an https URL of at most 2048 characters \
791             (RFC-ACDP-0018 §8.1)"
792        )));
793    }
794    Ok(())
795}
796
797// ── Subscriptions ────────────────────────────────────────────────────────────
798
799/// The `POST /subscriptions` body (RFC-ACDP-0018 §8.1), CLOSED.
800#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
801#[serde(deny_unknown_fields)]
802pub struct SubscriptionRequest {
803    /// The §6 filter hints are sent for.
804    pub filter: WatchFilter,
805    /// Where hints are POSTed: `https` only. The registry applies the
806    /// §8.3 SSRF rules at creation and at every delivery.
807    pub callback_url: String,
808}
809
810impl SubscriptionRequest {
811    /// A request for `filter`, delivered to `callback_url`.
812    pub fn new(filter: WatchFilter, callback_url: impl Into<String>) -> Self {
813        Self {
814            filter,
815            callback_url: callback_url.into(),
816        }
817    }
818
819    /// Parse and validate a request (the schema; not the §8.3 network
820    /// rules, which need DNS). Failures are [`AcdpError::SchemaViolation`].
821    pub fn from_value(value: &Value) -> Result<Self, AcdpError> {
822        check_filter_arrays(value.get("filter"))?;
823        let r = Self::deserialize(value)
824            .map_err(|e| bad(format!("subscription request does not parse: {e}")))?;
825        r.validate(None)?;
826        Ok(r)
827    }
828
829    /// The schema invariants serde does not enforce, plus the
830    /// `limits.max_change_filters` bound when given.
831    pub fn validate(&self, max_change_filters: Option<u64>) -> Result<(), AcdpError> {
832        self.filter.validate_for_subscription(max_change_filters)?;
833        check_callback_url(&self.callback_url)
834    }
835}
836
837/// A subscription's delivery state (§8.2).
838#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
839#[serde(rename_all = "snake_case")]
840pub enum SubscriptionStatus {
841    /// Hints are delivered.
842    Active,
843    /// Disabled after sustained delivery failure.
844    Disabled,
845}
846
847/// A change-hint subscription (RFC-ACDP-0018 §8.1), CLOSED. Owned by the
848/// requester DID that created it.
849#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
850#[serde(deny_unknown_fields)]
851pub struct Subscription {
852    /// Canonical lowercase v4 UUID.
853    pub subscription_id: String,
854    /// The §6 filter hints are sent for.
855    pub filter: WatchFilter,
856    /// Where hints are POSTed.
857    pub callback_url: String,
858    /// When the subscription was created (canonical millisecond UTC).
859    #[serde(with = "crate::receipt::ms_rfc3339")]
860    pub created_at: DateTime<Utc>,
861    /// Delivery state.
862    pub status: SubscriptionStatus,
863    /// Base64 of at least 32 random bytes: the `ACDP-Signature` HMAC key.
864    /// Present ONLY in the `201` creation response; never served again.
865    #[serde(
866        default,
867        skip_serializing_if = "Option::is_none",
868        deserialize_with = "de_present"
869    )]
870    pub secret: Option<String>,
871}
872
873impl Subscription {
874    /// Parse and validate a subscription object. Failures are
875    /// [`AcdpError::SchemaViolation`].
876    pub fn from_value(value: &Value) -> Result<Self, AcdpError> {
877        check_filter_arrays(value.get("filter"))?;
878        if let Some(raw) = value.get("created_at").and_then(Value::as_str) {
879            parse_ms(raw, "created_at")?;
880        }
881        let s = Self::deserialize(value)
882            .map_err(|e| bad(format!("subscription does not parse: {e}")))?;
883        s.validate()?;
884        Ok(s)
885    }
886
887    /// The schema invariants serde does not enforce.
888    pub fn validate(&self) -> Result<(), AcdpError> {
889        if !is_uuid_v4(&self.subscription_id) {
890            return Err(bad(format!(
891                "subscription_id '{}' is not a canonical lowercase v4 UUID",
892                self.subscription_id
893            )));
894        }
895        self.filter.validate_for_subscription(None)?;
896        check_callback_url(&self.callback_url)?;
897        if let Some(secret) = &self.secret {
898            if !is_secret_form(secret) {
899                return Err(bad(
900                    "subscription secret is not base64 of at least 32 bytes (RFC-ACDP-0018 §8.1)",
901                ));
902            }
903        }
904        Ok(())
905    }
906
907    /// This subscription as `GET /subscriptions/{id}` serves it: without
908    /// `secret`.
909    pub fn without_secret(&self) -> Self {
910        Self {
911            secret: None,
912            ..self.clone()
913        }
914    }
915}
916
917/// `^[A-Za-z0-9+/]{43,}={0,2}$`: base64 of at least 32 bytes.
918fn is_secret_form(s: &str) -> bool {
919    let body = s.trim_end_matches('=');
920    s.len() - body.len() <= 2
921        && body.len() >= 43
922        && body
923            .bytes()
924            .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
925}
926
927/// `^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`.
928fn is_uuid_v4(s: &str) -> bool {
929    let b = s.as_bytes();
930    b.len() == 36
931        && b.iter().enumerate().all(|(i, &c)| match i {
932            8 | 13 | 18 | 23 => c == b'-',
933            14 => c == b'4',
934            19 => matches!(c, b'8' | b'9' | b'a' | b'b'),
935            _ => c.is_ascii_digit() || (b'a'..=b'f').contains(&c),
936        })
937}
938
939// ── Change hints ─────────────────────────────────────────────────────────────
940
941/// An `acdp-change-hint/1` webhook body (RFC-ACDP-0018 §8.2), CLOSED.
942///
943/// It carries no change content. The receiver pulls `GET /changes` from
944/// its OWN persisted cursor; `head_cursor` is informational, and jumping
945/// to it skips entries.
946#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
947#[serde(deny_unknown_fields)]
948pub struct ChangeHint {
949    /// Always [`CHANGE_HINT_VERSION`].
950    pub hint_version: String,
951    /// The subscription the hint is for.
952    pub subscription_id: String,
953    /// The registry's authority (hostname).
954    pub registry_authority: String,
955    /// The feed head when the hint was built. Informational only.
956    pub head_cursor: String,
957}
958
959impl ChangeHint {
960    /// A hint for `subscription_id` from `registry_authority`.
961    pub fn new(
962        subscription_id: impl Into<String>,
963        registry_authority: impl Into<String>,
964        head_cursor: impl Into<String>,
965    ) -> Self {
966        Self {
967            hint_version: CHANGE_HINT_VERSION.into(),
968            subscription_id: subscription_id.into(),
969            registry_authority: registry_authority.into(),
970            head_cursor: head_cursor.into(),
971        }
972    }
973
974    /// Parse and validate a hint body. Verify its `ACDP-Signature` over
975    /// the raw bytes first ([`verify_hint_signature`]). Failures are
976    /// [`AcdpError::SchemaViolation`].
977    pub fn from_value(value: &Value) -> Result<Self, AcdpError> {
978        let h = Self::deserialize(value)
979            .map_err(|e| bad(format!("change hint does not parse: {e}")))?;
980        h.validate()?;
981        Ok(h)
982    }
983
984    /// The schema invariants serde does not enforce.
985    pub fn validate(&self) -> Result<(), AcdpError> {
986        if self.hint_version != CHANGE_HINT_VERSION {
987            return Err(bad(format!(
988                "hint_version '{}' ≠ '{CHANGE_HINT_VERSION}'",
989                self.hint_version
990            )));
991        }
992        if !is_uuid_v4(&self.subscription_id) {
993            return Err(bad(format!(
994                "subscription_id '{}' is not a canonical lowercase v4 UUID",
995                self.subscription_id
996            )));
997        }
998        if !is_valid_dns_authority(&self.registry_authority) {
999            return Err(bad(format!(
1000                "registry_authority '{}' is not a hostname",
1001                self.registry_authority
1002            )));
1003        }
1004        check_cursor(&self.head_cursor, "head_cursor")
1005    }
1006}
1007
1008// ── ACDP-Signature (HMAC-SHA-256) ────────────────────────────────────────────
1009
1010/// The HMAC key for a subscription `secret`: the UTF-8 bytes of the
1011/// base64 string exactly as the creation response served it (not the
1012/// decoded random bytes).
1013fn hint_key(secret: &str) -> &[u8] {
1014    secret.as_bytes()
1015}
1016
1017fn hint_mac(secret: &str, t: i64, body: &[u8]) -> Hmac<Sha256> {
1018    let mut mac =
1019        Hmac::<Sha256>::new_from_slice(hint_key(secret)).expect("HMAC accepts any key length");
1020    mac.update(t.to_string().as_bytes());
1021    mac.update(b".");
1022    mac.update(body);
1023    mac
1024}
1025
1026/// The `ACDP-Signature` header value for a hint body, registry side:
1027/// `t=<t>,v1=<hex HMAC-SHA-256(secret, "<t>." + body)>` (RFC-ACDP-0018
1028/// §8.2). `body` is the exact bytes that will be POSTed; `t` is unix
1029/// seconds.
1030pub fn sign_hint(secret: &str, t: i64, body: &[u8]) -> String {
1031    let tag = hint_mac(secret, t, body).finalize().into_bytes();
1032    format!("t={t},v1={}", hex::encode(tag))
1033}
1034
1035/// Verify a hint's `ACDP-Signature` header, receiver side (RFC-ACDP-0018
1036/// §8.2, fixture `rec-007` D).
1037///
1038/// `header` is the header value, `body` the raw request body bytes
1039/// exactly as received (never a re-serialization), `now` the receiver's
1040/// clock. Passes when `t` is within [`HINT_MAX_SKEW_SECONDS`] of `now`
1041/// and any `v1` equals the HMAC, compared in constant time. Members
1042/// other than `t` and `v1` are ignored, so a later `v2` does not break
1043/// receivers.
1044///
1045/// A pass authenticates the hint to its subscriber and nothing more: it
1046/// is not evidence for any change, and the receiver still pulls the feed
1047/// from its own cursor. Failures are [`AcdpError::NotAuthorized`].
1048pub fn verify_hint_signature(
1049    secret: &str,
1050    header: &str,
1051    body: &[u8],
1052    now: DateTime<Utc>,
1053) -> Result<(), AcdpError> {
1054    let reject = |why: &str| {
1055        AcdpError::NotAuthorized(format!(
1056            "ACDP-Signature rejected: {why} (RFC-ACDP-0018 §8.2)"
1057        ))
1058    };
1059    let mut t: Option<i64> = None;
1060    let mut v1: Vec<Vec<u8>> = Vec::new();
1061    for part in header.split(',') {
1062        let Some((k, v)) = part.trim().split_once('=') else {
1063            return Err(reject("malformed header"));
1064        };
1065        match k {
1066            "t" => {
1067                if t.is_some() || v.is_empty() || !v.bytes().all(|b| b.is_ascii_digit()) {
1068                    return Err(reject("t is missing, repeated or not unix seconds"));
1069                }
1070                t = Some(v.parse().map_err(|_| reject("t is out of range"))?);
1071            }
1072            "v1" => v1.push(hex::decode(v).map_err(|_| reject("v1 is not hex"))?),
1073            _ => {}
1074        }
1075    }
1076    let t = t.ok_or_else(|| reject("no t"))?;
1077    if v1.is_empty() {
1078        return Err(reject("no v1"));
1079    }
1080    if (now.timestamp() - t).abs() > HINT_MAX_SKEW_SECONDS {
1081        return Err(reject(
1082            "t is more than 300 seconds from the receiver's clock",
1083        ));
1084    }
1085    let mac = hint_mac(secret, t, body);
1086    if v1.iter().any(|tag| mac.clone().verify_slice(tag).is_ok()) {
1087        Ok(())
1088    } else {
1089        Err(reject("v1 does not match"))
1090    }
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095    use super::*;
1096    use crate::receipt::ReceiptSigner;
1097    use acdp_crypto::SigningKey;
1098    use serde_json::json;
1099
1100    const CTX: &str = "acdp://registry.example.com/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
1101    const NEW: &str = "acdp://registry.example.com/cccccccc-cccc-4ccc-8ccc-cccccccccccc";
1102    const LIN: &str = "lin:sha256:a3cb6a87c1601b9f6dddd8d54709b01df3e05f1095b43e6d58e3a862bf9906fa";
1103    const AGENT: &str = "did:web:agents.example.com:test-producer";
1104    const FP: &str = "sha256:6a3803d5f059902a1c6dafbc9ba4729212f7caac08634cc3ae76b27529f03827";
1105
1106    fn at(s: &str) -> DateTime<Utc> {
1107        DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
1108    }
1109
1110    fn registry() -> ReceiptSigner {
1111        ReceiptSigner::new(
1112            SigningKey::from_bytes(&[0x11; 32]),
1113            "did:web:registry.example.com",
1114            "did:web:registry.example.com#receipt-key-1",
1115        )
1116        .unwrap()
1117    }
1118
1119    fn event() -> Value {
1120        let e = LifecycleEvent::new(
1121            "00000000-0000-4000-8000-000000000098",
1122            CtxId::parse(CTX).unwrap(),
1123            crate::lifecycle::LifecycleEventType::Retracted,
1124            at("2026-10-03T00:00:00.000Z"),
1125            AgentDid::new(AGENT),
1126            None,
1127        )
1128        .unwrap()
1129        .sign_with(SigningKey::from_bytes(&[0; 32]), format!("{AGENT}#key-1"))
1130        .unwrap();
1131        serde_json::to_value(e).unwrap()
1132    }
1133
1134    fn lifecycle_change() -> Change {
1135        let r = registry()
1136            .mint_event_receipt(
1137                &CtxId::parse(CTX).unwrap(),
1138                &event(),
1139                0,
1140                at("2026-10-03T00:00:01.000Z"),
1141            )
1142            .unwrap();
1143        Change::lifecycle(
1144            1042,
1145            LineageId::parse(LIN).unwrap(),
1146            AgentDid::new(AGENT),
1147            event(),
1148            &r,
1149        )
1150        .unwrap()
1151    }
1152
1153    fn receipt_for(ctx: &str, created: &str) -> RegistryReceipt {
1154        registry()
1155            .mint(
1156                &CtxId::parse(ctx).unwrap(),
1157                &LineageId::parse(LIN).unwrap(),
1158                "registry.example.com",
1159                at(created),
1160                &acdp_primitives::primitives::ContentHash::parse(
1161                    "sha256:1e5ec1be1bb3dc4ee2c9ec6eb4c6cc6422b4ee4a862b6e947aee8de772b6e879",
1162                )
1163                .unwrap(),
1164                FP,
1165            )
1166            .unwrap()
1167    }
1168
1169    #[test]
1170    fn constructors_round_trip_and_are_consistent() {
1171        let l = lifecycle_change();
1172        let s = Change::superseded(
1173            1050,
1174            CtxId::parse(CTX).unwrap(),
1175            AgentDid::new(AGENT),
1176            &receipt_for(NEW, "2026-10-04T00:00:00.000Z"),
1177        )
1178        .unwrap();
1179        let k = Change::key_revocation(
1180            1057,
1181            AgentDid::new(AGENT),
1182            &receipt_for(NEW, "2026-10-05T00:00:00.000Z"),
1183        )
1184        .unwrap();
1185        for c in [&l, &s, &k] {
1186            c.check_consistency().unwrap();
1187            let wire = serde_json::to_value(c).unwrap();
1188            assert_eq!(wire["change_version"], CHANGE_VERSION);
1189            assert_eq!(wire["kind"], c.kind().as_str());
1190            assert_eq!(&Change::from_value(&wire).unwrap(), c);
1191        }
1192        assert_eq!(l.recorded_at(), at("2026-10-03T00:00:01.000Z"));
1193        assert_eq!(
1194            serde_json::to_value(&s).unwrap()["superseded_by"],
1195            json!(NEW)
1196        );
1197    }
1198
1199    #[test]
1200    fn cross_kind_members_are_rejected() {
1201        let wire = serde_json::to_value(lifecycle_change()).unwrap();
1202        let receipt = serde_json::to_value(receipt_for(NEW, "2026-10-04T00:00:00.000Z")).unwrap();
1203        type Mutate = Box<dyn Fn(&mut Value)>;
1204        let cases: Vec<(&str, Mutate)> = vec![
1205            ("lifecycle + registry_receipt", {
1206                let r = receipt.clone();
1207                Box::new(move |v: &mut Value| v["registry_receipt"] = r.clone())
1208            }),
1209            (
1210                "lifecycle + superseded_by",
1211                Box::new(|v: &mut Value| v["superseded_by"] = json!(NEW)),
1212            ),
1213            (
1214                "lifecycle without event_receipt",
1215                Box::new(|v: &mut Value| {
1216                    v.as_object_mut().unwrap().remove("event_receipt");
1217                }),
1218            ),
1219            (
1220                "superseded carrying event",
1221                Box::new(|v: &mut Value| v["kind"] = json!("superseded")),
1222            ),
1223            (
1224                "key_revocation carrying event",
1225                Box::new(|v: &mut Value| v["kind"] = json!("key_revocation")),
1226            ),
1227            (
1228                "unknown kind",
1229                Box::new(|v: &mut Value| v["kind"] = json!("deleted")),
1230            ),
1231            (
1232                "unknown member",
1233                Box::new(|v: &mut Value| v["x"] = json!(1)),
1234            ),
1235            (
1236                "wrong version",
1237                Box::new(|v: &mut Value| v["change_version"] = json!("acdp-change/2")),
1238            ),
1239            (
1240                "non-canonical recorded_at",
1241                Box::new(|v: &mut Value| v["recorded_at"] = json!("2026-10-03T00:00:01Z")),
1242            ),
1243            (
1244                "explicit null",
1245                Box::new(|v: &mut Value| v["superseded_by"] = Value::Null),
1246            ),
1247            (
1248                "event receipt with an unknown member",
1249                Box::new(|v: &mut Value| v["event_receipt"]["x"] = json!(1)),
1250            ),
1251        ];
1252        for (name, f) in cases {
1253            let mut v = wire.clone();
1254            f(&mut v);
1255            assert!(Change::from_value(&v).is_err(), "{name} parsed");
1256        }
1257    }
1258
1259    #[test]
1260    fn consistency_catches_envelope_disagreeing_with_evidence() {
1261        let mut v = serde_json::to_value(lifecycle_change()).unwrap();
1262        v["recorded_at"] = json!("2026-10-03T00:00:02.000Z");
1263        assert!(Change::from_value(&v).unwrap().check_consistency().is_err());
1264
1265        let mut v = serde_json::to_value(lifecycle_change()).unwrap();
1266        v["ctx_id"] = json!(NEW);
1267        assert!(Change::from_value(&v).unwrap().check_consistency().is_err());
1268
1269        // A superseded entry whose receipt is the superseded context's own.
1270        let s = Change::superseded(
1271            1,
1272            CtxId::parse(CTX).unwrap(),
1273            AgentDid::new(AGENT),
1274            &receipt_for(CTX, "2026-10-04T00:00:00.000Z"),
1275        )
1276        .unwrap();
1277        assert!(s.check_consistency().is_err());
1278    }
1279
1280    #[test]
1281    fn changes_response_needs_cursor_and_ascending_seq() {
1282        let c = serde_json::to_value(lifecycle_change()).unwrap();
1283        let ok = json!({"changes": [c], "next_cursor": "c2VxOjEwNDI", "has_more": false});
1284        ChangesResponse::from_value(&ok).unwrap();
1285        for bad in [
1286            json!({"changes": [], "has_more": false}),
1287            json!({"changes": [], "next_cursor": "", "has_more": false}),
1288            json!({"changes": [], "next_cursor": "x".repeat(2049), "has_more": false}),
1289            json!({"changes": [c, c], "next_cursor": "c", "has_more": false}),
1290            json!({"changes": [], "next_cursor": "c", "has_more": false, "x": 1}),
1291        ] {
1292            assert!(ChangesResponse::from_value(&bad).is_err(), "{bad}");
1293        }
1294    }
1295
1296    #[test]
1297    fn watch_filter_semantics() {
1298        let l = lifecycle_change();
1299        let k = Change::key_revocation(
1300            1057,
1301            AgentDid::new(AGENT),
1302            &receipt_for(NEW, "2026-10-05T00:00:00.000Z"),
1303        )
1304        .unwrap();
1305        let none = |_: &CtxId| None;
1306        assert!(WatchFilter::new().matches(&l, none));
1307        // OR across members, AND with kind.
1308        let f = WatchFilter::new()
1309            .ctx_id(CtxId::parse(NEW).unwrap())
1310            .lineage_id(LineageId::parse(LIN).unwrap());
1311        assert!(f.matches(&l, none));
1312        assert!(!f.clone().kind(ChangeKind::Superseded).matches(&l, none));
1313        assert!(WatchFilter::new()
1314            .agent_id(AgentDid::new(AGENT))
1315            .matches(&l, none));
1316        // A ctx_id watch hears its producer's key revocation...
1317        let other_ctx = "acdp://registry.example.com/dddddddd-dddd-4ddd-8ddd-dddddddddddd";
1318        let watch = WatchFilter::new().ctx_id(CtxId::parse(other_ctx).unwrap());
1319        assert!(watch.matches(&k, |_| Some(AgentDid::new(AGENT))));
1320        assert!(!watch.matches(&k, |_| Some(AgentDid::new("did:web:other.example.com"))));
1321        // ...but not its producer's other lifecycle events.
1322        assert!(!watch.matches(&l, |_| Some(AgentDid::new(AGENT))));
1323        assert_eq!(
1324            f.query_pairs(),
1325            vec![("ctx_id", NEW.to_string()), ("lineage_id", LIN.to_string())]
1326        );
1327        assert!(WatchFilter::new()
1328            .kind(ChangeKind::Lifecycle)
1329            .kind(ChangeKind::Superseded)
1330            .validate(Some(1))
1331            .is_err());
1332    }
1333
1334    #[test]
1335    fn subscription_objects_validate() {
1336        let req = json!({
1337            "filter": {"lineage_id": [LIN], "kind": ["lifecycle", "key_revocation"]},
1338            "callback_url": "https://consumer.example.com/acdp/hints"
1339        });
1340        let r = SubscriptionRequest::from_value(&req).unwrap();
1341        assert_eq!(serde_json::to_value(&r).unwrap(), req);
1342        for bad in [
1343            json!({"filter": {}, "callback_url": "https://c.example.com/"}),
1344            json!({"filter": {"kind": []}, "callback_url": "https://c.example.com/"}),
1345            json!({"filter": {"kind": ["lifecycle", "lifecycle"]}, "callback_url": "https://c.example.com/"}),
1346            json!({"filter": {"kind": ["lifecycle"]}, "callback_url": "http://c.example.com/"}),
1347            json!({"filter": {"kind": ["lifecycle"]}, "callback_url": "https:///path"}),
1348            json!({"filter": {"x": ["y"]}, "callback_url": "https://c.example.com/"}),
1349        ] {
1350            assert!(SubscriptionRequest::from_value(&bad).is_err(), "{bad}");
1351        }
1352
1353        let created = json!({
1354            "subscription_id": "6f1c2a9e-3b4d-4e5f-8a6b-7c8d9e0f1a2b",
1355            "filter": {"lineage_id": [LIN]},
1356            "callback_url": "https://consumer.example.com/acdp/hints",
1357            "created_at": "2026-10-01T00:00:00.000Z",
1358            "status": "active",
1359            "secret": "q4C3i0gxkC8b7mSx2F5fW1dTnYp9Z0eLrJvH6uK3oAM="
1360        });
1361        let s = Subscription::from_value(&created).unwrap();
1362        assert_eq!(serde_json::to_value(&s).unwrap(), created);
1363        let served = serde_json::to_value(s.without_secret()).unwrap();
1364        assert!(served.get("secret").is_none());
1365        let mut short = created.clone();
1366        short["secret"] = json!("c2hvcnQ=");
1367        assert!(Subscription::from_value(&short).is_err());
1368        let mut upper = created.clone();
1369        upper["subscription_id"] = json!("6F1C2A9E-3B4D-4E5F-8A6B-7C8D9E0F1A2B");
1370        assert!(Subscription::from_value(&upper).is_err());
1371    }
1372
1373    #[test]
1374    fn hint_parses() {
1375        let h = json!({
1376            "hint_version": "acdp-change-hint/1",
1377            "subscription_id": "6f1c2a9e-3b4d-4e5f-8a6b-7c8d9e0f1a2b",
1378            "registry_authority": "registry.example.com",
1379            "head_cursor": "c2VxOjEwNTc"
1380        });
1381        let parsed = ChangeHint::from_value(&h).unwrap();
1382        assert_eq!(
1383            parsed,
1384            ChangeHint::new(
1385                "6f1c2a9e-3b4d-4e5f-8a6b-7c8d9e0f1a2b",
1386                "registry.example.com",
1387                "c2VxOjEwNTc"
1388            )
1389        );
1390        let mut content = h.clone();
1391        content["changes"] = json!([]);
1392        assert!(ChangeHint::from_value(&content).is_err());
1393    }
1394
1395    /// The HMAC-SHA-256 primitive against RFC 4231 test case 2.
1396    #[test]
1397    fn hmac_primitive_matches_rfc_4231() {
1398        let mut mac = Hmac::<Sha256>::new_from_slice(b"Jefe").unwrap();
1399        mac.update(b"what do ya want for nothing?");
1400        assert_eq!(
1401            hex::encode(mac.finalize().into_bytes()),
1402            "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
1403        );
1404    }
1405
1406    /// Spec KAT `hint-hmac-001` (rec-007 `vectors[0]`): the key is the
1407    /// secret string's UTF-8 bytes; the decoded-key MAC must not verify.
1408    #[test]
1409    fn hint_hmac_001() {
1410        let secret = "q4C3i0gxkC8b7mSx2F5fW1dTnYp9Z0eLrJvH6uK3oAM=";
1411        let body = br#"{"head_cursor":"c2VxOjEwNTc","hint_version":"acdp-change-hint/1","registry_authority":"registry.example.com","subscription_id":"6f1c2a9e-3b4d-4e5f-8a6b-7c8d9e0f1a2b"}"#;
1412        let t = 1_790_985_602;
1413        let expected =
1414            "t=1790985602,v1=a000c589a805e6a5ed4eeb7835a9e8051a879d067c17c86c3de28862f9e489de";
1415        let not =
1416            "t=1790985602,v1=3b096f13ceb0ff06e1f51542614551007d2a8547f8df1a6f5f38daa26d5f0aed";
1417        assert_eq!(sign_hint(secret, t, body), expected);
1418        let now = DateTime::from_timestamp(t, 0).unwrap();
1419        verify_hint_signature(secret, expected, body, now).unwrap();
1420        assert!(verify_hint_signature(secret, not, body, now).is_err());
1421    }
1422
1423    #[test]
1424    fn hint_signature_round_trip_and_rejections() {
1425        let secret = "q4C3i0gxkC8b7mSx2F5fW1dTnYp9Z0eLrJvH6uK3oAM=";
1426        let body = br#"{"hint_version":"acdp-change-hint/1"}"#;
1427        let t = 1_790_000_000_i64;
1428        let now = DateTime::from_timestamp(t, 0).unwrap();
1429        let header = sign_hint(secret, t, body);
1430        assert!(header.starts_with(&format!("t={t},v1=")));
1431        verify_hint_signature(secret, &header, body, now).unwrap();
1432        // Order-insensitive, extra members ignored, any v1 may match.
1433        let v1 = header.split_once(",v1=").unwrap().1;
1434        verify_hint_signature(secret, &format!("v1={v1}, t={t},v2=zz"), body, now).unwrap();
1435        verify_hint_signature(
1436            secret,
1437            &format!("t={t},v1={},v1={v1}", "00".repeat(32)),
1438            body,
1439            now,
1440        )
1441        .unwrap();
1442        // Skew: 300 s passes, 301 s fails, both directions.
1443        for (d, ok) in [(300, true), (-300, true), (301, false), (-301, false)] {
1444            let r =
1445                verify_hint_signature(secret, &header, body, now + chrono::Duration::seconds(d));
1446            assert_eq!(r.is_ok(), ok, "skew {d}");
1447        }
1448        // Wrong v1, wrong secret, changed body, malformed headers.
1449        let mut flipped = header.clone();
1450        let last = flipped.pop().unwrap();
1451        flipped.push(if last == '0' { '1' } else { '0' });
1452        for (s, h, b) in [
1453            (secret, flipped.as_str(), &body[..]),
1454            (
1455                "another-secret-another-secret-another-secret",
1456                header.as_str(),
1457                &body[..],
1458            ),
1459            (secret, header.as_str(), &b"{}"[..]),
1460            (secret, "v1=00", &body[..]),
1461            (secret, &format!("t={t}"), &body[..]),
1462            (secret, &format!("t=-{t},v1={v1}"), &body[..]),
1463            (secret, &format!("t={t},t={t},v1={v1}"), &body[..]),
1464            (secret, "garbage", &body[..]),
1465        ] {
1466            assert!(
1467                matches!(
1468                    verify_hint_signature(s, h, b, now),
1469                    Err(AcdpError::NotAuthorized(_))
1470                ),
1471                "{h} accepted"
1472            );
1473        }
1474    }
1475}