vtc-service 0.12.0

Service for Verifiable Trust Communities
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
//! The Facts contract — the purpose-agnostic policy `input` every
//! ceremony shares (ceremony-pipeline design §3).
//!
//! One typed shape feeds every `<purpose>.rego` module. It is the
//! greenfield replacement for the MVP's lossy
//! [`crate::policy::extract::extract_vp_claims`] projection: instead
//! of handing the policy a flattened `vp_claims` blob, the host hands
//! it **structured, pre-verified facts** — `actor` / `subject` /
//! `context` / `evidence` / `state`. Crypto (signatures,
//! holder-binding, revocation, issuer-trust) is resolved by the host
//! *before* a [`Facts`] is ever assembled (see
//! [`crate::ceremony::verify`]); the policy therefore reasons only
//! over booleans + claims, never over a signature.
//!
//! ## Wire shape is load-bearing
//!
//! These structs serialize to the exact JSON the Rule-IR-compiled
//! Rego reads — `snake_case` keys, `evidence.presentation.
//! credentials[].issuer_trusted`, `state.subject_member`, and so on.
//! The runnable policies under
//! `docs/05-design-notes/examples/*.rego` are the ground truth for
//! field names; the round-trip tests at the bottom of this module
//! lock the serialized shape against those examples. Renaming a field
//! here silently breaks every compiled policy that reads it, so the
//! `#[serde(rename = …)]` / `rename_all` attributes are part of the
//! contract, not cosmetic.
//!
//! ## Optionality convention
//!
//! - Evidence slots a ceremony doesn't use are **absent**
//!   (`skip_serializing_if`), not `null` — `directory` facts carry
//!   only `evidence.request`. Rego treats absent + `null` identically
//!   (both `undefined`), so the compiled helpers (`cred_trusted`,
//!   `has_valid_invitation`, …) defend against both.
//! - `state.subject_member` is the one slot serialized as explicit
//!   `null` when empty (an unknown DID joining has no member row) —
//!   the design example shows it present-but-null, and policies read
//!   `input.state.subject_member` directly.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;

use crate::credentials::task_context::TaskContextBinding;
use crate::credentials::witness::WitnessBinding;

/// Which ceremony this evaluation is deciding.
///
/// This is the **pipeline** vocabulary (`docs/05-design-notes/
/// vtc-ceremony-catalog.md`), deliberately distinct from the MVP's
/// nine-variant [`crate::policy::model::PolicyPurpose`]: the
/// greenfield pipeline replaces "nine bespoke per-purpose flows" with
/// one pipeline parameterized by this enum (pipeline §10). The
/// variants here are the four ceremonies that have worked examples +
/// compiled policies under `docs/05-design-notes/examples/`; more
/// land as ceremonies are ported onto the pipeline.
///
/// Naming reconciliation with `PolicyPurpose` is an open migration
/// item (pipeline §12, decisions 7): the MVP calls the destructive
/// ceremony `removal`; the pipeline calls it `leave`. The wire
/// strings here follow the catalog (`kebab-case`: `role-change`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Purpose {
    /// A DID joining the community (constructive; actor usually ==
    /// subject; threaded — supports `request_more` / `refer`).
    Join,
    /// A member departing or being removed (destructive; actor may
    /// differ from subject; no-last-admin invariant applies).
    Leave,
    /// A member's role being changed in place (mutating; the one
    /// ceremony whose `allow` may grant `admin`, gated by step-up).
    RoleChange,
    /// A read-only directory query (synchronous, unthreaded; `allow`
    /// returns a field projection rather than a state mutation).
    Directory,
    /// An administrator admitting a DID directly, without a join request —
    /// adding a member, or making an existing ACL entry a membership
    /// (constructive; actor ≠ subject unless an administrator makes their
    /// own entry a membership; synchronous — `refer` parks the operation in
    /// the action list for another administrator's approval).
    Admission,
}

impl Purpose {
    /// Stable wire string (matches the serde representation and the
    /// `purpose` field of the example facts files).
    pub fn as_str(self) -> &'static str {
        match self {
            Purpose::Join => "join",
            Purpose::Leave => "leave",
            Purpose::RoleChange => "role-change",
            Purpose::Directory => "directory",
            Purpose::Admission => "admission",
        }
    }

    /// The Rego package whose `decision` rule decides this ceremony.
    /// Identifiers can't carry hyphens, so `role-change` →
    /// `vtc.role_change`.
    ///
    /// Note the [`Purpose::Leave`] → `vtc.removal` mapping: the
    /// pipeline's friendly name for the destructive ceremony is
    /// "leave", but it reuses the MVP's `removal` policy purpose
    /// ([`crate::policy::model::PolicyPurpose::Removal`]) and package
    /// rather than introducing a parallel `leave` purpose — settling
    /// the leave/removal naming drift (pipeline §12) in favour of the
    /// established runtime name. Facts still serialize `purpose:
    /// "leave"`; only the policy package is `vtc.removal`.
    pub fn rego_package(self) -> &'static str {
        match self {
            Purpose::Join => "vtc.join",
            Purpose::Leave => "vtc.removal",
            Purpose::RoleChange => "vtc.role_change",
            Purpose::Directory => "vtc.directory",
            Purpose::Admission => "vtc.admission",
        }
    }

    /// The full query the evaluate stage runs against a compiled
    /// policy for this purpose — the `decision` rule in the purpose's
    /// package. Every ceremony policy exposes a single `decision`
    /// object (pipeline §4), so this is the one query the host needs.
    pub fn decision_query(self) -> String {
        format!("data.{}.decision", self.rego_package())
    }
}

/// The purpose-agnostic policy input. Assembled by the host after
/// verification; consumed by `<purpose>.rego`.
///
/// Construct a [`Facts`] directly only at the host boundary that has
/// just finished verifying evidence — downstream code should take a
/// [`crate::ceremony::verify::VerifiedFacts`], which can only be
/// produced by running [`Facts`] through the verification gate.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Facts {
    /// Which ceremony is being decided. Selects the policy module
    /// and the effect handler.
    pub purpose: Purpose,
    /// Evaluation timestamp. Policies compare credential
    /// `valid_until` / member `joined_at` against this rather than
    /// reading a wall-clock, so a simulation can pin "now".
    pub now: DateTime<Utc>,
    /// Who initiated the transition.
    pub actor: Actor,
    /// Who the transition is *about*. May equal [`Actor::did`]
    /// (self-join, self-leave) or differ (admin removing a member).
    pub subject: Subject,
    /// Ambient community facts the policy may branch on.
    pub context: Context,
    /// What the actor presented. Ceremonies populate only the slots
    /// they use.
    pub evidence: Evidence,
    /// Authoritative current state relevant to the decision, read
    /// from the ACL / member keyspaces.
    pub state: State,
}

/// The authenticated initiator of the ceremony.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Actor {
    /// The actor's DID (the proven signer at the route layer).
    pub did: String,
    /// The actor's community role from the ACL, when they are a
    /// member. Absent for an unknown DID (e.g. an applicant joining).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub role: Option<String>,
    /// Whether the route layer authenticated this actor. The host
    /// rejects truly anonymous triggers before assembling facts; the
    /// flag is surfaced so policies can branch (e.g. an open-join
    /// route that tolerates an unauthenticated applicant).
    pub authenticated: bool,
}

/// The DID the transition concerns.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Subject {
    pub did: String,
}

/// Ambient community context.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Context {
    /// The community this ceremony runs against.
    pub community_did: String,
    /// Transport the trigger arrived over (`"rest"` / `"didcomm"`).
    pub channel: String,
    /// Current member count — feeds size-sensitive policy (e.g. a
    /// quorum threshold or a first-member bootstrap branch).
    pub member_count: u64,
    /// The trust task exchange this ceremony *is* — the `threadId` the
    /// evidence arrived on. Present for a threaded ceremony (the
    /// credential-exchange join, whose thread keys the single-use
    /// presentation challenge); absent for a synchronous REST
    /// submission, which has no thread.
    ///
    /// This is what makes each credential's
    /// [`Credential::task_context`] verdict mean something: without
    /// naming the exchange in the facts, "the credential says it came
    /// from thread X" is unanswerable, and a credential from another
    /// exchange satisfies a policy rule as readily as one from this
    /// one (DTG Credentials Security Considerations 5, context
    /// collapse).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub thread_id: Option<String>,
}

/// Everything the actor presented, grouped by kind. Each slot is
/// independently optional; a ceremony populates the slots it needs.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Evidence {
    /// A community invitation credential (VIC), when the actor
    /// presented one. Absent for open-join / directory.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub invitation: Option<Invitation>,
    /// A verifiable presentation's verified projection, when the
    /// actor presented credentials.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub presentation: Option<Presentation>,
    /// Ceremony-specific request parameters (e.g. `agreements` for
    /// join, `disposition` / `reason` for leave, `fields_requested`
    /// for directory, `target_role` for role-change). Free-form in
    /// Phase 1; tightened to per-purpose typed requests later.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub request: Option<JsonValue>,
    /// Peer identity vetting evidence — the host's verification and count of
    /// the vetting statements a join presentation carried, when the criterion
    /// requires vetting. Absent otherwise.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vetting: Option<crate::vetting::VettingFacts>,
    /// The join criterion the submission is decided under, and whether it is
    /// met (`vtc/join-requests/submit/0.3`). Present on every join decision;
    /// the host holds the policy to it ([`super::invariant`]).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub criterion: Option<crate::join::criteria::CriterionFact>,
}

/// A verified invitation credential (VIC). All fields are
/// post-verification facts — `verified` is the host's verdict on the
/// invitation's signature + binding, not a self-asserted flag.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Invitation {
    /// Host verdict: did the invitation's signature + binding check
    /// out. Policy reads this; it never sees the signature.
    pub verified: bool,
    /// DID that issued the invitation (community DID or a delegating
    /// member).
    pub issuer: String,
    /// The issuer's community role at issue time, when known —
    /// distinguishes a community-issued invite from a member
    /// delegation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub issuer_role: Option<String>,
    /// Host verdict: does the community trust this issuer to issue
    /// invitations. `true` when the issuer is the community itself
    /// (self-issued) or a registry-recognised third party. The
    /// compiled `has_valid_invitation` helper requires this, so a
    /// genuinely-signed invite from an untrusted issuer is refused.
    /// `#[serde(default)]` keeps pre-existing facts JSON (no
    /// `issuer_trusted` key) deserialising as `false`.
    #[serde(default)]
    pub issuer_trusted: bool,
    /// Scopes the invitation authorizes (e.g. role bounds,
    /// single-context).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub scopes: Vec<String>,
    /// Whether this single-use invitation has already been consumed —
    /// the host tracks consumption like the bootstrap carve-out, and
    /// the compiled `has_valid_invitation` helper rejects a consumed
    /// invite.
    pub consumed: bool,
}

/// The verified projection of a presented VP.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Presentation {
    /// Host verdict: did the VP proof + holder-binding check out.
    pub verified: bool,
    /// The proven holder DID.
    pub holder: String,
    /// The credentials inside the VP, each already verified.
    #[serde(default)]
    pub credentials: Vec<Credential>,
}

/// One verified credential from a presentation. Crypto is already
/// resolved — `issuer_trusted` is the host's TRQP/governance verdict
/// and `status` is the resolved status-list state, both computed
/// before the policy runs.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Credential {
    /// The credential `type` — the concrete DTG subtype (e.g.
    /// `MembershipCredential`, `StatementCredential`) or an SD-JWT-VC `vct`.
    /// Serialized as `type` to match the compiled helpers (`cred_trusted(t)`
    /// matches on `c.type`).
    #[serde(rename = "type")]
    pub credential_type: String,
    /// For a DTG statement, its `credentialSubject.predicate` — what the
    /// statement means. Statements share one `type`, so a policy tells a
    /// witness (`https://registry.trustoverip.org/dtg/vsc/witnessed/1`) from
    /// an endorsement by this, never by `type` (`statement_trusted(p)`).
    /// Absent for every other credential.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub predicate: Option<String>,
    /// Issuer DID.
    pub issuer: String,
    /// Host verdict: is the issuer trusted for this credential type
    /// under the community's governance (resolved via TRQP, not
    /// hardcoded).
    pub issuer_trusted: bool,
    /// Resolved revocation/suspension state from the status list.
    pub status: CredentialStatus,
    /// Host verdict: did the presenter **cryptographically prove control of
    /// the holder key** (so the presenter *is* the subject), versus mere
    /// possession of the credential? True for SD-JWT-VC (`kb-jwt`), DI VP
    /// (holder proof), and holder-bound **bbs-2023 pseudonym** proofs; false
    /// for a basic, possession-based bbs-2023 derived proof. A policy can
    /// `require` this for sensitive communities (low-assurance flows may accept
    /// possession-based holdership).
    #[serde(default)]
    pub holder_bound: bool,
    /// The credential's subject claims, verbatim. Free-form; policies
    /// read specific claim paths.
    #[serde(default)]
    pub claims: JsonValue,
    /// Expiry, when the credential carries one. Policies compare
    /// against [`Facts::now`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub valid_until: Option<DateTime<Utc>>,
    /// Host verdict: for a `witnessed/1` statement (a VWC), which edge its
    /// `credentialSubject.object.digestMultibase` actually names — recomputed
    /// against the VRC bodies this service holds, comparing decoded digest
    /// bytes, and holding the profile's subject–object rule.
    ///
    /// `None` for every other credential type, where the question does not
    /// arise. Present and [`WitnessBinding::Unresolved`] is a different fact
    /// from absent, and a policy that conflates them accepts a witness whose
    /// digest was never checked.
    ///
    /// Resolved by the host for the same reason `issuer_trusted` and `status`
    /// are: the policy branches on a settled state instead of doing
    /// cryptography.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub witness_binding: Option<WitnessBinding>,
    /// Host verdict: which trust task exchange this credential's own
    /// `taskContext` names, relative to
    /// [`Context::thread_id`] — the exchange it is being presented in.
    ///
    /// `None` where the path never looked (the raw-VP submit path
    /// verifies nothing about an embedded VC, so it cannot report on
    /// this either). Present and
    /// [`TaskContextBinding::ForeignExchange`] is a different fact
    /// from absent: it says the credential was issued in some *other*
    /// exchange, which is ordinary for a witness and disqualifying for
    /// anything offered as evidence that *this* ceremony reached its
    /// terminal state (DTG Credentials Security Considerations 5).
    ///
    /// A policy asserting a ceremony outcome should require
    /// `task_context.state == "sameExchange"`; one merely weighing
    /// background evidence should not, or it refuses every honest
    /// witness.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub task_context: Option<TaskContextBinding>,
}

/// Resolved credential status. The host computes this from the
/// status-list lookup before evaluation, so the policy branches on a
/// settled state rather than performing a revocation check itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CredentialStatus {
    /// Active and not revoked or suspended.
    Valid,
    /// Permanently revoked.
    Revoked,
    /// Temporarily suspended.
    Suspended,
    /// Status could not be resolved (e.g. status list unreachable).
    /// Surfaced rather than guessed so a policy can choose to refuse.
    Unknown,
}

/// Authoritative current state relevant to the decision.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct State {
    /// The subject's current member row, or `null` if the subject is
    /// not (yet) a member. Serialized as explicit `null` when empty
    /// so policies can read `input.state.subject_member` directly.
    pub subject_member: Option<MemberState>,
}

/// The subject's current membership, when they are a member.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MemberState {
    /// Current community role.
    pub role: String,
    /// Membership status (e.g. `"active"`).
    pub status: String,
    /// When the subject joined — feeds tenure-sensitive policy.
    pub joined_at: DateTime<Utc>,
    /// Personhood assertion state, when established. Free-form in
    /// Phase 1.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub personhood: Option<JsonValue>,
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    /// A join applicant with a trusted witness credential — mirrors
    /// `docs/05-design-notes/examples/facts.join.json` exactly. This
    /// is the contract test: if the serialized shape drifts from the
    /// example the compiled `join.rego` reads, this fails.
    #[test]
    fn join_facts_match_design_example() {
        let facts = Facts {
            purpose: Purpose::Join,
            now: "2026-05-30T12:00:00Z".parse().unwrap(),
            actor: Actor {
                did: "did:key:z6MkHuman".into(),
                role: None,
                authenticated: true,
            },
            subject: Subject {
                did: "did:key:z6MkHuman".into(),
            },
            context: Context {
                community_did: "did:webvh:acme.example".into(),
                channel: "rest".into(),
                member_count: 1421,
                thread_id: None,
            },
            evidence: Evidence {
                vetting: None,
                invitation: None,
                presentation: Some(Presentation {
                    verified: true,
                    holder: "did:key:z6MkHuman".into(),
                    credentials: vec![Credential {
                        credential_type: "StatementCredential".into(),
                        predicate: Some(dtg_credentials::WITNESSED_V1.into()),
                        issuer: "did:webvh:notary.example".into(),
                        issuer_trusted: true,
                        status: CredentialStatus::Valid,
                        holder_bound: true,
                        claims: json!({ "kind": "proximity" }),
                        valid_until: None,
                        witness_binding: None,
                        task_context: None,
                    }],
                }),
                request: Some(json!({ "agreements": {} })),
                criterion: None,
            },
            state: State {
                subject_member: None,
            },
        };

        let expected = json!({
            "purpose": "join",
            "now": "2026-05-30T12:00:00Z",
            "actor": { "did": "did:key:z6MkHuman", "authenticated": true },
            "subject": { "did": "did:key:z6MkHuman" },
            "context": { "community_did": "did:webvh:acme.example", "channel": "rest", "member_count": 1421 },
            "evidence": {
                "presentation": {
                    "verified": true,
                    "holder": "did:key:z6MkHuman",
                    "credentials": [
                        { "type": "StatementCredential", "predicate": dtg_credentials::WITNESSED_V1, "issuer": "did:webvh:notary.example", "issuer_trusted": true, "status": "valid", "holder_bound": true, "claims": { "kind": "proximity" } }
                    ]
                },
                "request": { "agreements": {} }
            },
            "state": { "subject_member": null }
        });

        assert_eq!(serde_json::to_value(&facts).unwrap(), expected);
        // And it round-trips back from the wire shape.
        let parsed: Facts = serde_json::from_value(expected).unwrap();
        assert_eq!(parsed, facts);
    }

    /// Directory facts carry `actor.role` + a populated
    /// `subject_member` and omit the unused evidence slots — mirrors
    /// `facts.directory.json`.
    #[test]
    fn directory_facts_omit_unused_evidence_slots() {
        let facts = Facts {
            purpose: Purpose::Directory,
            now: "2026-05-30T12:00:00Z".parse().unwrap(),
            actor: Actor {
                did: "did:key:z6MkViewer".into(),
                role: Some("member".into()),
                authenticated: true,
            },
            subject: Subject {
                did: "did:key:z6MkTarget".into(),
            },
            context: Context {
                community_did: "did:webvh:acme.example".into(),
                channel: "rest".into(),
                member_count: 1421,
                thread_id: None,
            },
            evidence: Evidence {
                vetting: None,
                invitation: None,
                presentation: None,
                request: Some(json!({ "fields_requested": ["did", "role", "joined_at"] })),
                criterion: None,
            },
            state: State {
                subject_member: Some(MemberState {
                    role: "member".into(),
                    status: "active".into(),
                    joined_at: "2026-03-03T00:00:00Z".parse().unwrap(),
                    personhood: None,
                }),
            },
        };

        let wire = serde_json::to_value(&facts).unwrap();
        // Unused evidence slots are absent, not null.
        assert!(wire["evidence"].get("invitation").is_none());
        assert!(wire["evidence"].get("presentation").is_none());
        assert_eq!(wire["actor"]["role"], "member");
        assert_eq!(wire["state"]["subject_member"]["role"], "member");
        assert_eq!(
            wire["evidence"]["request"]["fields_requested"],
            json!(["did", "role", "joined_at"])
        );
    }

    #[test]
    fn purpose_wire_strings_match_catalog() {
        for (purpose, wire) in [
            (Purpose::Join, "join"),
            (Purpose::Leave, "leave"),
            (Purpose::RoleChange, "role-change"),
            (Purpose::Directory, "directory"),
            (Purpose::Admission, "admission"),
        ] {
            assert_eq!(serde_json::to_value(purpose).unwrap(), json!(wire));
            assert_eq!(purpose.as_str(), wire);
            let parsed: Purpose = serde_json::from_value(json!(wire)).unwrap();
            assert_eq!(parsed, purpose);
        }
    }

    /// The task-context binding reaches the policy under the keys a rule is
    /// written against — `context.thread_id` names the exchange, and each
    /// credential's `task_context.state` says where *it* came from. The pair is
    /// what makes "a credential from another exchange" expressible at all; a
    /// rule reading either key by a different name matches nothing, and
    /// matching nothing is indistinguishable from finding no foreign
    /// credential.
    #[test]
    fn the_task_context_binding_reaches_the_policy_input() {
        let mut facts = Facts {
            purpose: Purpose::Join,
            now: "2026-05-30T12:00:00Z".parse().unwrap(),
            actor: Actor {
                did: "did:key:z6MkHuman".into(),
                role: None,
                authenticated: true,
            },
            subject: Subject {
                did: "did:key:z6MkHuman".into(),
            },
            context: Context {
                community_did: "did:webvh:acme.example".into(),
                channel: "didcomm".into(),
                member_count: 1,
                thread_id: Some("urn:uuid:this-exchange".into()),
            },
            evidence: Evidence {
                vetting: None,
                invitation: None,
                presentation: Some(Presentation {
                    verified: true,
                    holder: "did:key:z6MkHuman".into(),
                    credentials: vec![Credential {
                        credential_type: "StatementCredential".into(),
                        predicate: Some(dtg_credentials::WITNESSED_V1.into()),
                        issuer: "did:webvh:notary.example".into(),
                        issuer_trusted: true,
                        status: CredentialStatus::Valid,
                        holder_bound: true,
                        claims: json!({}),
                        valid_until: None,
                        witness_binding: None,
                        task_context: Some(TaskContextBinding::ForeignExchange {
                            thread_id: "urn:uuid:some-other-exchange".into(),
                        }),
                    }],
                }),
                request: None,
                criterion: None,
            },
            state: State {
                subject_member: None,
            },
        };

        let wire = serde_json::to_value(&facts).unwrap();
        assert_eq!(wire["context"]["thread_id"], "urn:uuid:this-exchange");
        let cred = &wire["evidence"]["presentation"]["credentials"][0];
        assert_eq!(cred["task_context"]["state"], "foreignExchange");
        assert_eq!(
            cred["task_context"]["thread_id"],
            "urn:uuid:some-other-exchange"
        );
        let parsed: Facts = serde_json::from_value(wire).unwrap();
        assert_eq!(parsed, facts);

        // Unthreaded ceremonies and unexamined credentials omit the keys rather
        // than sending `null`, matching every other optional slot — a Rego rule
        // sees `undefined` either way, and the design examples carry neither.
        facts.context.thread_id = None;
        facts.evidence.presentation.as_mut().unwrap().credentials[0].task_context = None;
        let wire = serde_json::to_value(&facts).unwrap();
        assert!(wire["context"].get("thread_id").is_none());
        assert!(
            wire["evidence"]["presentation"]["credentials"][0]
                .get("task_context")
                .is_none()
        );
    }

    #[test]
    fn credential_status_is_lowercase() {
        assert_eq!(
            serde_json::to_value(CredentialStatus::Valid).unwrap(),
            json!("valid")
        );
        assert_eq!(
            serde_json::to_value(CredentialStatus::Revoked).unwrap(),
            json!("revoked")
        );
        let parsed: CredentialStatus = serde_json::from_value(json!("unknown")).unwrap();
        assert_eq!(parsed, CredentialStatus::Unknown);
    }
}