treeship-core 0.11.2

Portable trust receipts for agent workflows - core library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
//! Agent invitation statement -- Phase 1 of the agent-invitations spec.
//!
//! See `docs/specs/agent-invitations-rooms.md`.
//!
//! An invitation is structurally an Approval Grant for `action_type =
//! "session.join"`. The host (the session's owning signing key) mints a
//! single-use, expiring, restriction-bound grant; the joining agent
//! redeems it by emitting a participant event (see
//! `session_participant.rs`). Replay protection comes from the existing
//! Approval Use Journal -- the invitation's nonce is hashed into a
//! `nonce_digest` and the journal rejects double-consumption.
//!
//! Phase 1 scope (decisions locked in by the maintainer):
//!
//! * `invitee_restriction` default is `Cert` for production. `Pubkey` is
//!   the tighter option; `Open` is opt-in only.
//! * `expires_at` default is 1 hour; the protocol-level maximum is 7
//!   days, enforced at mint time via `validate_for_mint`.
//! * `max_uses` is always 1 in Phase 1 -- the schema carries it for
//!   forward-compat but mint rejects any other value.
//! * Authority is HostOnly: the issuer pubkey is the session's owning
//!   signing key. Delegation is Phase 2.
//!
//! The canonical signing string follows the v0.10.4 pattern: a
//! pipe-delimited line that binds every field that participates in
//! verification dispatch. New fields added in future versions go through
//! a `canonical_version` bump, not a silent extension.

use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::attestation::{Signer, SignerError};

// ---------------------------------------------------------------------------
// Type constants
// ---------------------------------------------------------------------------

pub const TYPE_INVITATION: &str = "treeship/invitation/v1";

/// Maximum allowed lifetime of a freshly minted invitation, in seconds.
/// 7 days. Enforced at mint time. Verifiers do NOT re-check this bound
/// (an invitation that was minted under a different binary with a
/// looser bound would still verify cryptographically; the protocol-level
/// guarantee is "the host promised to bound their own mints").
pub const MAX_INVITATION_LIFETIME_SECS: u64 = 7 * 24 * 60 * 60;

/// Default invitation lifetime when the operator does not specify one.
/// 1 hour. Matches the recommendation in the spec.
pub const DEFAULT_INVITATION_LIFETIME_SECS: u64 = 60 * 60;

// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------

/// Who may redeem an invitation. Three shapes; the default for new
/// invitations is `Cert`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InviteeRestriction {
    /// Tightest: only the agent whose pubkey hashes to `fingerprint` may
    /// join. Fingerprint is `sha256(canonical_pubkey)`'s first 16 hex
    /// chars, matching `pubkey_fingerprint` in the trust CLI.
    Pubkey { fingerprint: String },
    /// Production sweet spot: any agent holding a certificate issued by
    /// `issuer_pubkey` whose subject is in `allowed_subjects`.
    Cert {
        issuer_pubkey: String,
        allowed_subjects: Vec<String>,
    },
    /// Anyone holding the blob may redeem. Opt-in only; the CLI refuses
    /// to mint an Open invitation without an explicit `--open` flag.
    Open,
}

/// Capabilities granted to the joining agent. Phase 1 carries only
/// `action_types`; `workflow_node_ids` comes in Phase 3 once PR #107
/// (workflow declarations) lands.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GrantedCapabilities {
    /// Dot-namespaced action labels the joining agent is authorized to
    /// emit (e.g. `["tool.call", "agent.handoff"]`). Empty means no
    /// capabilities (degenerate; the CLI warns).
    #[serde(default)]
    pub action_types: Vec<String>,
}

/// One signed invitation. Wrap in a DSSE envelope via
/// `crate::attestation::sign` with `payload_type("invitation")` to seal.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvitationStatement {
    #[serde(rename = "type")]
    pub type_: String,

    /// `session_id` (e.g. `ssn_<hex>`) that this invitation joins.
    pub session_ref: String,

    /// Issuer's Ed25519 public key as base64url-no-pad. Verifiers MUST
    /// confirm this key is present in the trust root store under kind
    /// `SessionHost` before honoring the invitation.
    pub issuer: String,

    pub invitee_restriction: InviteeRestriction,

    pub granted_capabilities: GrantedCapabilities,

    /// RFC 3339 expiry timestamp.
    pub expires_at: String,

    /// Always 1 in Phase 1. The schema carries the field so multi-use
    /// invitations (roadmap) don't need a canonical-format bump.
    pub max_uses: u32,

    /// Random hex-encoded nonce. The Approval Use Journal indexes its
    /// SHA-256 digest, so the journal never sees the raw nonce.
    pub nonce: String,
}

impl InvitationStatement {
    /// Construct an invitation with the current canonical type tag.
    pub fn new(
        session_ref: impl Into<String>,
        issuer: impl Into<String>,
        invitee_restriction: InviteeRestriction,
        granted_capabilities: GrantedCapabilities,
        expires_at: impl Into<String>,
        nonce: impl Into<String>,
    ) -> Self {
        Self {
            type_:                TYPE_INVITATION.into(),
            session_ref:          session_ref.into(),
            issuer:               issuer.into(),
            invitee_restriction,
            granted_capabilities,
            expires_at:           expires_at.into(),
            max_uses:             1,
            nonce:                nonce.into(),
        }
    }

    /// Canonical signing bytes. Pipe-delimited, version-prefixed,
    /// following the v0.10.4 `Checkpoint::canonical_for_signing` shape.
    ///
    /// Format:
    /// `"v1|invitation|{session_ref}|{issuer}|{restriction_canonical}|{capabilities_canonical}|{expires_at}|{max_uses}|{nonce_digest}"`
    ///
    /// `restriction_canonical` and `capabilities_canonical` are
    /// `sha256:<hex>` digests over the sorted-key canonical JSON
    /// serialization of the field. Hashing them keeps the canonical
    /// string a single line regardless of field cardinality (cert
    /// `allowed_subjects` is a Vec; embedding it directly would require
    /// a sub-delimiter and reopen the parser-mismatch attack surface
    /// that pipe-delimited canonicals are designed to avoid).
    ///
    /// `nonce_digest` (not the raw nonce) is bound for the same reason
    /// the Approval Use Journal stores the digest: the raw nonce is
    /// already in the signed envelope's payload bytes, so binding the
    /// digest into the canonical adds redundancy without exposing the
    /// nonce in a second place.
    pub fn canonical_for_signing(&self) -> String {
        let restriction_digest  = canonical_json_digest(&self.invitee_restriction);
        let capabilities_digest = canonical_json_digest(&self.granted_capabilities);
        let nonce_d             = nonce_digest_hex(&self.nonce);
        format!(
            "v1|invitation|{}|{}|{}|{}|{}|{}|{}",
            self.session_ref,
            self.issuer,
            restriction_digest,
            capabilities_digest,
            self.expires_at,
            self.max_uses,
            nonce_d,
        )
    }

    /// Sign the invitation under the host's keypair. The signature is
    /// over the canonical bytes (see `canonical_for_signing`), encoded
    /// as base64url-no-pad. The signed envelope (DSSE) is produced by
    /// callers via `crate::attestation::sign`; this helper produces
    /// just the raw signature so callers can compose either way.
    pub fn sign_canonical(&self, signer: &dyn Signer) -> Result<String, SignerError> {
        let canonical = self.canonical_for_signing();
        let sig = signer.sign(canonical.as_bytes())?;
        Ok(URL_SAFE_NO_PAD.encode(sig))
    }

    /// Verify the supplied `signature_b64url` against `self.issuer`'s
    /// pubkey over the canonical bytes. Returns `true` only when both
    /// the pubkey decodes cleanly AND the signature math checks out.
    /// Does NOT consult trust roots -- the caller is responsible for
    /// checking that `self.issuer` is pinned under kind `SessionHost`.
    pub fn verify_canonical(&self, signature_b64url: &str) -> bool {
        let pk_bytes = match URL_SAFE_NO_PAD.decode(self.issuer.as_bytes()) {
            Ok(b) if b.len() == 32 => b,
            _ => return false,
        };
        let sig_bytes = match URL_SAFE_NO_PAD.decode(signature_b64url.as_bytes()) {
            Ok(b) if b.len() == 64 => b,
            _ => return false,
        };
        let mut pk_arr = [0u8; 32];
        pk_arr.copy_from_slice(&pk_bytes);
        let mut sig_arr = [0u8; 64];
        sig_arr.copy_from_slice(&sig_bytes);
        let vk = match VerifyingKey::from_bytes(&pk_arr) {
            Ok(k)  => k,
            Err(_) => return false,
        };
        let sig = Signature::from_bytes(&sig_arr);
        vk.verify(self.canonical_for_signing().as_bytes(), &sig).is_ok()
    }

    /// Mint-time validation: rejects invitations that violate
    /// protocol-level invariants the verifier alone cannot enforce.
    ///
    /// * `expires_at` parses as RFC 3339 and is in the future.
    /// * `expires_at - now_unix_secs` does not exceed
    ///   `MAX_INVITATION_LIFETIME_SECS` (7 days).
    /// * `max_uses == 1` (Phase 1).
    /// * `session_ref` and `nonce` are non-empty.
    /// * `issuer` decodes as a 32-byte Ed25519 pubkey.
    pub fn validate_for_mint(&self, now_unix_secs: u64) -> Result<(), InvitationError> {
        if self.session_ref.trim().is_empty() {
            return Err(InvitationError::EmptyField("session_ref"));
        }
        if self.nonce.trim().is_empty() {
            return Err(InvitationError::EmptyField("nonce"));
        }
        if self.max_uses != 1 {
            return Err(InvitationError::MaxUsesUnsupported { max_uses: self.max_uses });
        }
        // issuer parse check
        let pk_bytes = URL_SAFE_NO_PAD
            .decode(self.issuer.as_bytes())
            .map_err(|_| InvitationError::IssuerNotEd25519)?;
        if pk_bytes.len() != 32 {
            return Err(InvitationError::IssuerNotEd25519);
        }
        let expires_secs = parse_rfc3339_to_unix(&self.expires_at)
            .ok_or(InvitationError::ExpiresAtNotRfc3339)?;
        if expires_secs <= now_unix_secs {
            return Err(InvitationError::ExpiresInPast);
        }
        let lifetime = expires_secs - now_unix_secs;
        if lifetime > MAX_INVITATION_LIFETIME_SECS {
            return Err(InvitationError::LifetimeTooLong {
                requested_secs: lifetime,
                max_secs:       MAX_INVITATION_LIFETIME_SECS,
            });
        }
        Ok(())
    }

    /// True when `now_unix_secs >= expires_at`. Verifiers call this at
    /// redeem time. Returns true on a malformed `expires_at` so that a
    /// tampered field fails closed.
    pub fn is_expired(&self, now_unix_secs: u64) -> bool {
        match parse_rfc3339_to_unix(&self.expires_at) {
            Some(secs) => now_unix_secs >= secs,
            None       => true,
        }
    }

    /// Returns `sha256(<raw nonce>)` in `sha256:<hex>` form. Same digest
    /// the Approval Use Journal indexes by; callers route invitation
    /// consumption through the journal using this value.
    pub fn nonce_digest(&self) -> String {
        nonce_digest_hex(&self.nonce)
    }
}

// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InvitationError {
    EmptyField(&'static str),
    IssuerNotEd25519,
    ExpiresAtNotRfc3339,
    ExpiresInPast,
    LifetimeTooLong { requested_secs: u64, max_secs: u64 },
    MaxUsesUnsupported { max_uses: u32 },
}

impl std::fmt::Display for InvitationError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::EmptyField(name) => write!(f, "invitation field {name} must not be empty"),
            Self::IssuerNotEd25519 => write!(
                f,
                "invitation issuer must decode to a 32-byte Ed25519 public key (base64url-no-pad)",
            ),
            Self::ExpiresAtNotRfc3339 => write!(
                f,
                "invitation expires_at must be RFC 3339 (e.g. 2026-05-18T12:00:00Z)",
            ),
            Self::ExpiresInPast => write!(f, "invitation expires_at must be in the future at mint time"),
            Self::LifetimeTooLong { requested_secs, max_secs } => write!(
                f,
                "invitation lifetime {requested_secs}s exceeds protocol max {max_secs}s ({} days)",
                max_secs / (24 * 60 * 60),
            ),
            Self::MaxUsesUnsupported { max_uses } => write!(
                f,
                "invitation max_uses must be 1 in Phase 1 (got {max_uses}); \
                 multi-use invitations are a future-version feature",
            ),
        }
    }
}

impl std::error::Error for InvitationError {}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Helper used by both invitation + participant canonicals: a deterministic
/// digest over the sorted-key canonical JSON of a serializable value.
/// Folds variable-length fields into a fixed-length string so the
/// pipe-delimited canonical stays single-line and unambiguous.
///
/// Panics if the value cannot serialize -- caller types are all in-crate
/// concrete structs/enums with primitive fields, so failure here would
/// signal a programming bug (same audit lane C rationale as the
/// approval_use record-digest helpers).
pub(crate) fn canonical_json_digest<T: Serialize>(value: &T) -> String {
    let json_value = serde_json::to_value(value)
        .expect("canonical_json_digest: serialize must not fail for in-crate types");
    let canonical = canonical_json_string(&json_value);
    let digest = Sha256::digest(canonical.as_bytes());
    format!("sha256:{}", hex::encode(digest))
}

/// Sorted-key canonical JSON. Mirrors `merkle::checkpoint::canonical_json_string`
/// (intentionally a copy rather than a cross-module pub use; the merkle
/// version is private and this module needs the same behavior without
/// reaching into a sibling's internals).
fn canonical_json_string(value: &serde_json::Value) -> String {
    use std::collections::BTreeMap;
    match value {
        serde_json::Value::Object(map) => {
            let sorted: BTreeMap<&String, String> = map
                .iter()
                .map(|(k, v)| (k, canonical_json_string(v)))
                .collect();
            let mut out = String::from("{");
            let mut first = true;
            for (k, v) in sorted {
                if !first { out.push(','); }
                first = false;
                let key_json = serde_json::to_string(k)
                    .expect("string serializes to JSON");
                out.push_str(&key_json);
                out.push(':');
                out.push_str(&v);
            }
            out.push('}');
            out
        }
        serde_json::Value::Array(items) => {
            let mut out = String::from("[");
            let mut first = true;
            for v in items {
                if !first { out.push(','); }
                first = false;
                out.push_str(&canonical_json_string(v));
            }
            out.push(']');
            out
        }
        other => serde_json::to_string(other)
            .expect("scalar JSON value serializes"),
    }
}

/// `sha256(<raw_nonce>)` as `sha256:<hex>`. Shared with the journal
/// (`statements::approval_use::nonce_digest`); kept here so the
/// invitation module does not depend on the journal-side type.
fn nonce_digest_hex(raw_nonce: &str) -> String {
    let digest = Sha256::digest(raw_nonce.as_bytes());
    format!("sha256:{}", hex::encode(digest))
}

/// Parse RFC 3339 / ISO 8601 timestamps in the subset Treeship emits
/// (`YYYY-MM-DDTHH:MM:SSZ`). Returns Unix epoch seconds. Returns
/// `None` on any parse failure -- callers that need a hard error
/// translate this into the appropriate `InvitationError`.
///
/// We deliberately do not pull in `chrono` here -- the statements module
/// is dep-light by design and already implements `unix_to_rfc3339`. This
/// is the inverse.
fn parse_rfc3339_to_unix(s: &str) -> Option<u64> {
    // Strict shape: 20 bytes, "YYYY-MM-DDTHH:MM:SSZ".
    let b = s.as_bytes();
    if b.len() != 20 || b[10] != b'T' || b[19] != b'Z'
        || b[4] != b'-' || b[7] != b'-'
        || b[13] != b':' || b[16] != b':'
    {
        return None;
    }
    let year:  i64 = std::str::from_utf8(&b[0..4]).ok()?.parse().ok()?;
    let month: u32 = std::str::from_utf8(&b[5..7]).ok()?.parse().ok()?;
    let day:   u32 = std::str::from_utf8(&b[8..10]).ok()?.parse().ok()?;
    let hour:  u32 = std::str::from_utf8(&b[11..13]).ok()?.parse().ok()?;
    let min:   u32 = std::str::from_utf8(&b[14..16]).ok()?.parse().ok()?;
    let sec:   u32 = std::str::from_utf8(&b[17..19]).ok()?.parse().ok()?;
    if !(1970..=9999).contains(&year)
        || !(1..=12).contains(&month)
        || !(1..=31).contains(&day)
        || hour > 23 || min > 59 || sec > 60
    {
        return None;
    }
    // Days since 1970-01-01 to start of (year, month, day).
    let mut days: i64 = 0;
    for y in 1970..year {
        days += if is_leap(y as u64) { 366 } else { 365 };
    }
    let months = if is_leap(year as u64) {
        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    } else {
        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    };
    for m in 1..month {
        days += months[(m - 1) as usize];
    }
    days += (day - 1) as i64;
    let total = days * 86_400 + (hour as i64) * 3600 + (min as i64) * 60 + (sec as i64);
    if total < 0 { return None; }
    Some(total as u64)
}

fn is_leap(y: u64) -> bool {
    (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
}

/// Generate a random hex-encoded nonce suitable for an invitation.
/// 16 bytes -> 32 hex chars; matches the entropy of an Ed25519 keyid.
pub fn generate_nonce() -> String {
    use rand::{rngs::OsRng, RngCore};
    let mut buf = [0u8; 16];
    OsRng.fill_bytes(&mut buf);
    hex::encode(buf)
}

/// `sha256(<canonical_pk>)` truncated to first 16 hex chars. Mirrors
/// `pubkey_fingerprint` in the trust CLI so a `Pubkey` restriction can
/// be checked against either input format. Operators paste either form
/// into `--invitee-pubkey`.
pub fn pubkey_fingerprint_short(canonical_pk: &str) -> String {
    let bytes = Sha256::digest(canonical_pk.as_bytes());
    hex::encode(bytes)[..16].to_string()
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::attestation::Ed25519Signer;

    fn sample_caps() -> GrantedCapabilities {
        GrantedCapabilities {
            action_types: vec!["tool.call".into(), "agent.handoff".into()],
        }
    }

    fn host_signer() -> Ed25519Signer {
        Ed25519Signer::from_bytes("host_key", &[7u8; 32]).unwrap()
    }

    fn fixed_now() -> u64 {
        // 2026-05-18T00:00:00Z
        1_779_580_800
    }

    fn one_hour_after(now: u64) -> String {
        crate::statements::unix_to_rfc3339(now + 3600)
    }

    fn sample(restriction: InviteeRestriction) -> InvitationStatement {
        let signer = host_signer();
        let issuer = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
        InvitationStatement::new(
            "ssn_room_abc",
            issuer,
            restriction,
            sample_caps(),
            one_hour_after(fixed_now()),
            "nonce_deadbeef",
        )
    }

    #[test]
    fn invitation_round_trips_serde() {
        let inv = sample(InviteeRestriction::Open);
        let bytes = serde_json::to_vec(&inv).unwrap();
        let back: InvitationStatement = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(back.session_ref, inv.session_ref);
        assert_eq!(back.type_, TYPE_INVITATION);
        assert_eq!(back.max_uses, 1);
    }

    /// The canonical signing bytes MUST include every field. Mutating
    /// any one of them must change the canonical (and thus break the
    /// signature). This pins the audit-lane-D property: no
    /// wire-controllable field is unbound.
    #[test]
    fn invitation_canonical_includes_all_fields() {
        let base = sample(InviteeRestriction::Cert {
            issuer_pubkey:    "ed25519:AAA".into(),
            allowed_subjects: vec!["org-x".into()],
        });
        let base_canonical = base.canonical_for_signing();

        let mut m1 = base.clone(); m1.session_ref = "ssn_other".into();
        assert_ne!(m1.canonical_for_signing(), base_canonical, "session_ref must bind");

        let mut m2 = base.clone(); m2.issuer = URL_SAFE_NO_PAD.encode([9u8; 32]);
        assert_ne!(m2.canonical_for_signing(), base_canonical, "issuer must bind");

        let mut m3 = base.clone();
        m3.invitee_restriction = InviteeRestriction::Open;
        assert_ne!(m3.canonical_for_signing(), base_canonical, "restriction must bind");

        let mut m4 = base.clone();
        m4.granted_capabilities.action_types.push("extra.cap".into());
        assert_ne!(m4.canonical_for_signing(), base_canonical, "capabilities must bind");

        let mut m5 = base.clone(); m5.expires_at = one_hour_after(fixed_now() + 1);
        assert_ne!(m5.canonical_for_signing(), base_canonical, "expires_at must bind");

        // max_uses is locked at 1 in Phase 1, but the schema field is
        // bound into the canonical so a future relax doesn't silently
        // verify older invitations under the wrong value.
        let mut m6 = base.clone(); m6.max_uses = 2;
        assert_ne!(m6.canonical_for_signing(), base_canonical, "max_uses must bind");

        let mut m7 = base.clone(); m7.nonce = "nonce_other".into();
        assert_ne!(m7.canonical_for_signing(), base_canonical, "nonce must bind");
    }

    #[test]
    fn invitation_sign_and_verify_roundtrip() {
        let inv = sample(InviteeRestriction::Open);
        let signer = host_signer();
        let sig = inv.sign_canonical(&signer).unwrap();
        assert!(inv.verify_canonical(&sig));
    }

    #[test]
    fn invitation_verify_rejects_wrong_signature() {
        let inv = sample(InviteeRestriction::Open);
        // Sign with a different key than `inv.issuer`.
        let attacker = Ed25519Signer::from_bytes("att", &[3u8; 32]).unwrap();
        let sig = inv.sign_canonical(&attacker).unwrap();
        assert!(!inv.verify_canonical(&sig));
    }

    #[test]
    fn invitation_verify_rejects_tampered_canonical() {
        let mut inv = sample(InviteeRestriction::Open);
        let signer = host_signer();
        let sig = inv.sign_canonical(&signer).unwrap();
        // Mutate after signing -- verification must fail.
        inv.session_ref = "ssn_tampered".into();
        assert!(!inv.verify_canonical(&sig));
    }

    /// Q2 default: invitations MUST NOT mint with > 7d expiry.
    #[test]
    fn invitation_expiry_max_7d_enforced() {
        let now = fixed_now();
        let signer = host_signer();
        let issuer = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());

        // 7 days + 1 second -> rejected.
        let too_long = crate::statements::unix_to_rfc3339(now + MAX_INVITATION_LIFETIME_SECS + 1);
        let inv = InvitationStatement::new(
            "ssn_a", issuer.clone(),
            InviteeRestriction::Open, sample_caps(),
            too_long, "n1",
        );
        match inv.validate_for_mint(now) {
            Err(InvitationError::LifetimeTooLong { .. }) => {}
            other => panic!("expected LifetimeTooLong, got {other:?}"),
        }

        // Exactly 7 days -> accepted.
        let exact = crate::statements::unix_to_rfc3339(now + MAX_INVITATION_LIFETIME_SECS);
        let inv_ok = InvitationStatement::new(
            "ssn_a", issuer,
            InviteeRestriction::Open, sample_caps(),
            exact, "n2",
        );
        assert!(inv_ok.validate_for_mint(now).is_ok());
    }

    #[test]
    fn invitation_validate_rejects_past_expiry() {
        let now = fixed_now();
        let signer = host_signer();
        let issuer = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
        let past = crate::statements::unix_to_rfc3339(now - 60);
        let inv = InvitationStatement::new(
            "ssn_a", issuer, InviteeRestriction::Open, sample_caps(), past, "n",
        );
        assert_eq!(inv.validate_for_mint(now), Err(InvitationError::ExpiresInPast));
    }

    #[test]
    fn invitation_validate_rejects_max_uses_not_one() {
        let now = fixed_now();
        let mut inv = sample(InviteeRestriction::Open);
        inv.max_uses = 2;
        match inv.validate_for_mint(now) {
            Err(InvitationError::MaxUsesUnsupported { max_uses }) => assert_eq!(max_uses, 2),
            other => panic!("expected MaxUsesUnsupported, got {other:?}"),
        }
    }

    /// Q1: Pubkey restriction rejects join with a wrong pubkey.
    #[test]
    fn invitation_pubkey_restriction_enforced() {
        // Mint a Pubkey-restricted invitation against signer_a's fp.
        let signer_a   = Ed25519Signer::from_bytes("a", &[1u8; 32]).unwrap();
        let signer_b   = Ed25519Signer::from_bytes("b", &[2u8; 32]).unwrap();
        let fp_a = pubkey_fingerprint_short(&format!(
            "ed25519:{}",
            URL_SAFE_NO_PAD.encode(signer_a.public_key_bytes()),
        ));
        let fp_b = pubkey_fingerprint_short(&format!(
            "ed25519:{}",
            URL_SAFE_NO_PAD.encode(signer_b.public_key_bytes()),
        ));
        assert_ne!(fp_a, fp_b);

        let restriction = InviteeRestriction::Pubkey { fingerprint: fp_a.clone() };

        // Join-time check (mirrors what the CLI does on `session join`):
        // accept iff the joining agent's pubkey-fp equals the restriction's fp.
        let accept_for = |fp: &str| matches!(
            &restriction,
            InviteeRestriction::Pubkey { fingerprint } if fingerprint == fp,
        );
        assert!(accept_for(&fp_a),  "matching pubkey must be accepted");
        assert!(!accept_for(&fp_b), "non-matching pubkey must be rejected");
    }

    /// Q1: Cert restriction rejects join without a matching cert.
    #[test]
    fn invitation_cert_restriction_enforced() {
        let restriction = InviteeRestriction::Cert {
            issuer_pubkey:    "ed25519:ISSUER_X".into(),
            allowed_subjects: vec!["org-x".into(), "org-y".into()],
        };
        // Helper: would this (issuer, subject) be accepted?
        let accept = |iss: &str, subj: &str| matches!(
            &restriction,
            InviteeRestriction::Cert { issuer_pubkey, allowed_subjects }
                if issuer_pubkey == iss && allowed_subjects.iter().any(|s| s == subj),
        );

        assert!(accept("ed25519:ISSUER_X",     "org-x"), "matching issuer+subject accepted");
        assert!(!accept("ed25519:ISSUER_OTHER", "org-x"), "wrong issuer rejected");
        assert!(!accept("ed25519:ISSUER_X",     "org-z"), "wrong subject rejected");
    }

    /// Q1: Open restriction accepts any joining agent.
    #[test]
    fn invitation_open_restriction_works() {
        let restriction = InviteeRestriction::Open;
        // Open is unconditionally accepted at restriction-check time.
        // Defense in depth still comes from the journal (single-use)
        // and the expiry.
        let is_open = matches!(restriction, InviteeRestriction::Open);
        assert!(is_open);
    }

    #[test]
    fn invitation_is_expired_returns_true_past_expiry() {
        let now = fixed_now();
        let inv = InvitationStatement::new(
            "ssn_a", URL_SAFE_NO_PAD.encode([5u8; 32]),
            InviteeRestriction::Open, sample_caps(),
            crate::statements::unix_to_rfc3339(now - 1),
            "n",
        );
        assert!(inv.is_expired(now));
    }

    /// The nonce_digest helper matches the journal's nonce digest helper.
    /// Pins that invitations and the Approval Use Journal will agree on
    /// the index key when the CLI routes invitation consumption through
    /// the journal.
    #[test]
    fn invitation_nonce_digest_matches_journal_helper() {
        let inv = sample(InviteeRestriction::Open);
        assert_eq!(
            inv.nonce_digest(),
            crate::statements::nonce_digest(&inv.nonce),
        );
    }

    #[test]
    fn parse_rfc3339_round_trips() {
        let now = fixed_now();
        let s = crate::statements::unix_to_rfc3339(now);
        assert_eq!(parse_rfc3339_to_unix(&s), Some(now));

        // Bad shapes must return None, not panic.
        assert_eq!(parse_rfc3339_to_unix("not a timestamp"), None);
        assert_eq!(parse_rfc3339_to_unix("2026-05-18T00:00:00"), None); // no Z
        assert_eq!(parse_rfc3339_to_unix("2026-13-18T00:00:00Z"), None); // bad month
    }
}