meerkat-mobkit 0.8.22

Companion orchestration platform for the Meerkat multi-agent runtime
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
722
723
724
725
726
727
728
729
//! MobKit's lowering of Meerkat's application tool-policy contract.
//!
//! Meerkat 0.8.26 defines the compiled artifact, the provider and snapshot
//! traits, and the registry that binds a member to a policy, but it ships no
//! production implementation of either trait: the only ones in the published
//! crate live inside its own test module. This module is that implementation and
//! nothing more.
//!
//! Two boundaries here are deliberate rather than incidental.
//!
//! ACCEPTED-REVISION OWNERSHIP IS OURS. `ToolConsequenceNarrowingPolicy`
//! documents that the provider owns the snapshot pointer and must reject a
//! revision below one already accepted, because Meerkat "deliberately keeps no
//! second accepted-revision store". So the monotonic check lives here, and it
//! refuses using Meerkat's own vocabulary, `RevisionRollback` and
//! `RevisionDigestConflict`, rather than inventing a local error for a condition
//! upstream already named.
//!
//! EXECUTION AUTHORITY IS THE GRANT, NOT THE CONSEQUENCE CLASS. In v1 a grant is
//! one exact allow entry with no wildcards and no deny entries, its only action
//! is `Invoke`, and neither `ToolConsequenceRequest` nor
//! `ToolConsequenceVerdict` carries a threshold. So an exact member/tool grant
//! allows, a miss defers to the artifact's own `default_deny`, and
//! `CompiledToolConsequence` R0-R3 is reported for observability WITHOUT gating
//! execution. Choosing a threshold here would make MobKit the authority on what
//! R2 means, which is upstream's to define if it is ever wanted.

use std::collections::BTreeMap;
use std::sync::{Arc, RwLock};

use meerkat_core::{
    CompiledApplicationToolPolicy, CompiledMemberToolAction, PolicyDigest,
    PolicyEvaluationProvenance, PolicyId, PolicyProviderGeneration, PolicyProviderId,
    ToolConsequenceDenial, ToolConsequenceFailure, ToolConsequenceNarrowingPolicy,
    ToolConsequencePolicySnapshot, ToolConsequenceRequest, ToolConsequenceVerdict,
};

/// Denial code used when no exact grant covers the requested member and tool.
pub const DENIAL_CODE_NO_GRANT: &str = "application_tool_policy_no_grant";

/// One immutable compiled policy, served to Meerkat as a snapshot.
#[derive(Debug)]
pub struct CompiledPolicySnapshot {
    policy: CompiledApplicationToolPolicy,
}

impl CompiledPolicySnapshot {
    pub fn new(policy: CompiledApplicationToolPolicy) -> Self {
        Self { policy }
    }

    pub fn policy(&self) -> &CompiledApplicationToolPolicy {
        &self.policy
    }
}

impl ToolConsequencePolicySnapshot for CompiledPolicySnapshot {
    fn provenance(&self) -> PolicyEvaluationProvenance {
        PolicyEvaluationProvenance {
            revision: self.policy.revision,
            digest: self.policy.policy_digest.clone(),
        }
    }

    fn evaluate(&self, request: &ToolConsequenceRequest) -> ToolConsequenceVerdict {
        let grant = self
            .policy
            .members
            .iter()
            .find(|member| member.member_identity == request.member.member)
            .and_then(|member| {
                member
                    .grants
                    .iter()
                    .find(|grant| grant.tool_name == request.tool_name.as_str())
            });

        match grant {
            // The only action in v1 is `Invoke`, so an exact entry is the
            // decision. The consequence class travels in the observation, not
            // in the verdict.
            Some(grant) => match grant.action {
                CompiledMemberToolAction::Invoke => ToolConsequenceVerdict::Allow,
            },
            None if self.policy.default_deny => {
                ToolConsequenceVerdict::Deny(ToolConsequenceDenial::new(
                    DENIAL_CODE_NO_GRANT,
                    format!(
                        "no exact grant for tool '{}' and member '{}' in policy '{}' revision {}",
                        request.tool_name.as_str(),
                        request.member.member,
                        self.policy.policy_id,
                        self.policy.revision.0
                    ),
                ))
            }
            // `default_deny = false` is the artifact's own switch. Honouring it
            // is reading the policy, not authoring one.
            None => ToolConsequenceVerdict::Allow,
        }
    }
}

#[derive(Debug)]
struct AcceptedPolicy {
    revision: u64,
    digest: PolicyDigest,
    snapshot: Arc<CompiledPolicySnapshot>,
}

/// Serves the compiled policies MobKit was configured with, and owns the
/// accepted-revision fence Meerkat delegates to the provider.
#[derive(Debug)]
pub struct CompiledPolicyProvider {
    provider_id: PolicyProviderId,
    generation: PolicyProviderGeneration,
    accepted: RwLock<BTreeMap<PolicyId, AcceptedPolicy>>,
}

impl CompiledPolicyProvider {
    pub fn new(provider_id: PolicyProviderId, generation: PolicyProviderGeneration) -> Self {
        Self {
            provider_id,
            generation,
            accepted: RwLock::new(BTreeMap::new()),
        }
    }

    /// Install a compiled policy, refusing any revision that is not a forward
    /// move for its policy id.
    ///
    /// Rejects three things: a policy belonging to another provider, a revision
    /// below one already accepted, and a repeat of an accepted revision whose
    /// bytes differ. The last one matters because a silent content swap under a
    /// stable revision would make the digest the only evidence, and nothing
    /// downstream re-checks it.
    pub fn accept(
        &self,
        policy: CompiledApplicationToolPolicy,
    ) -> Result<(), ToolConsequenceFailure> {
        if policy.provider_id != self.provider_id {
            return Err(ToolConsequenceFailure::EvaluationFailed {
                reason: format!(
                    "compiled policy names provider '{}' but this provider is '{}'",
                    policy.provider_id, self.provider_id
                ),
            });
        }

        let mut accepted = self
            .accepted
            .write()
            .unwrap_or_else(std::sync::PoisonError::into_inner);

        if let Some(current) = accepted.get(&policy.policy_id) {
            if policy.revision.0 < current.revision {
                return Err(ToolConsequenceFailure::RevisionRollback {
                    provider_id: self.provider_id.clone(),
                    policy_id: policy.policy_id.clone(),
                    accepted_revision: current.revision,
                    observed_revision: policy.revision.0,
                });
            }
            if policy.revision.0 == current.revision && policy.policy_digest != current.digest {
                return Err(ToolConsequenceFailure::RevisionDigestConflict {
                    provider_id: self.provider_id.clone(),
                    policy_id: policy.policy_id.clone(),
                    revision: policy.revision.0,
                });
            }
        }

        let policy_id = policy.policy_id.clone();
        let revision = policy.revision.0;
        let digest = policy.policy_digest.clone();
        accepted.insert(
            policy_id,
            AcceptedPolicy {
                revision,
                digest,
                snapshot: Arc::new(CompiledPolicySnapshot::new(policy)),
            },
        );
        Ok(())
    }

    /// Parse and install one canonical compiled-policy payload.
    ///
    /// Parsing goes through `parse_canonical_json`, so unknown fields, missing
    /// fields, non-canonical byte form and a mismatched digest all fail here
    /// rather than at first evaluation.
    pub fn accept_canonical_json(&self, bytes: &[u8]) -> Result<(), ToolConsequenceFailure> {
        let policy =
            CompiledApplicationToolPolicy::parse_canonical_json(bytes).map_err(|error| {
                ToolConsequenceFailure::EvaluationFailed {
                    reason: format!("compiled application tool policy rejected: {error}"),
                }
            })?;
        self.accept(policy)
    }

    /// Revision currently accepted for `policy_id`, for diagnostics.
    pub fn accepted_revision(&self, policy_id: &PolicyId) -> Option<u64> {
        self.accepted
            .read()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .get(policy_id)
            .map(|accepted| accepted.revision)
    }
}

/// Read the `application_tool_policies` init parameter.
///
/// Absent means an empty list: a boot that declared nothing arms nothing. A
/// present value that is not an array of strings is an error rather than a
/// silent empty, because a host that supplied policies in the wrong shape
/// expects them in force and would otherwise see the refusal much later as an
/// unexplained access denial.
///
/// Lives here rather than inline in the gateway so the gateway, the wire
/// fixture test, and the Python SDK are all exercising ONE extraction. The
/// hardcoded-provider defect shipped precisely because this logic was
/// unreachable from a test while it sat in a binary.
pub fn compiled_policy_payloads_from_init_params(
    params: &serde_json::Value,
) -> Result<Vec<String>, ToolConsequenceFailure> {
    match params.get("application_tool_policies") {
        // ABSENT means empty. An explicit `null` is PRESENT and not an array of
        // strings, so it is malformed: a host that wrote the key expects it in
        // force, and treating null as absent would arm nothing while looking
        // like it had worked. This distinction is the whole fail-closed claim.
        None => Ok(Vec::new()),
        Some(value) => serde_json::from_value(value.clone()).map_err(|error| {
            ToolConsequenceFailure::EvaluationFailed {
                reason: format!("application_tool_policies is malformed: {error}"),
            }
        }),
    }
}

/// Build one provider per distinct provider id CARRIED by the supplied
/// canonical payloads.
///
/// The host must not name the provider. A compiled policy declares its own
/// author, a member's `ApplicationToolPolicyBinding::Provider { provider_id, .. }`
/// is resolved by that same carried id, and
/// `ToolConsequencePolicyRegistry::new` takes a vec precisely so one host can
/// serve several. Naming a provider here would refuse every policy compiled by
/// anyone else, which is not a narrower feature but a dead one.
///
/// Returns providers ordered by id, so a boot is deterministic in what it
/// registers.
pub fn providers_from_canonical_payloads<I, S>(
    payloads: I,
) -> Result<Vec<Arc<CompiledPolicyProvider>>, ToolConsequenceFailure>
where
    I: IntoIterator<Item = S>,
    S: AsRef<[u8]>,
{
    let mut by_provider: BTreeMap<PolicyProviderId, Arc<CompiledPolicyProvider>> = BTreeMap::new();
    for payload in payloads {
        let policy = CompiledApplicationToolPolicy::parse_canonical_json(payload.as_ref())
            .map_err(|error| ToolConsequenceFailure::EvaluationFailed {
                reason: format!("compiled application tool policy rejected: {error}"),
            })?;
        let provider_id = policy.provider_id.clone();
        let provider = by_provider.entry(provider_id.clone()).or_insert_with(|| {
            Arc::new(CompiledPolicyProvider::new(
                provider_id,
                PolicyProviderGeneration(1),
            ))
        });
        provider.accept(policy)?;
    }
    Ok(by_provider.into_values().collect())
}

impl ToolConsequenceNarrowingPolicy for CompiledPolicyProvider {
    fn provider_id(&self) -> &PolicyProviderId {
        &self.provider_id
    }

    fn generation(&self) -> PolicyProviderGeneration {
        self.generation
    }

    fn snapshot(
        &self,
        policy_id: &PolicyId,
    ) -> Result<Arc<dyn ToolConsequencePolicySnapshot>, ToolConsequenceFailure> {
        let accepted = self
            .accepted
            .read()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        match accepted.get(policy_id) {
            Some(accepted) => Ok(Arc::clone(&accepted.snapshot) as Arc<_>),
            None => Err(ToolConsequenceFailure::PolicyMissing {
                provider_id: self.provider_id.clone(),
                policy_id: policy_id.clone(),
            }),
        }
    }
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic)]
mod tests {
    use super::*;
    use meerkat_core::{
        CompiledMemberToolGrant, CompiledMemberToolGrants, CompiledPolicySourceProvenance,
        CompiledToolConsequence, MobMemberBinding, PolicyEvaluationSupervisorConfig,
        PolicyRevision, ToolConsequencePolicyRegistry, ToolName,
    };

    fn provider_id() -> PolicyProviderId {
        PolicyProviderId::new("mobkit-test-provider").expect("provider id")
    }

    fn policy_id() -> PolicyId {
        PolicyId::new("fleet-baseline").expect("policy id")
    }

    fn source() -> CompiledPolicySourceProvenance {
        CompiledPolicySourceProvenance {
            source_id: "mobkit-test-source".to_string(),
            source_digest: PolicyDigest::from_canonical_bytes(b"mobkit-test-source"),
        }
    }

    fn grants_for(member: &str, tool: &str) -> Vec<CompiledMemberToolGrants> {
        vec![CompiledMemberToolGrants {
            member_identity: member.to_string(),
            grants: vec![CompiledMemberToolGrant {
                tool_name: tool.to_string(),
                action: CompiledMemberToolAction::Invoke,
                consequence: CompiledToolConsequence::R2,
            }],
        }]
    }

    fn policy(revision: u64, member: &str, tool: &str) -> CompiledApplicationToolPolicy {
        CompiledApplicationToolPolicy::new(
            provider_id(),
            policy_id(),
            PolicyRevision(revision),
            source(),
            grants_for(member, tool),
        )
        .expect("compiled policy should validate")
    }

    fn request(member: &str, tool: &str) -> ToolConsequenceRequest {
        ToolConsequenceRequest {
            member: MobMemberBinding {
                mob_id: "mob".to_string(),
                role: "worker".to_string(),
                member: member.to_string(),
            },
            tool_name: ToolName::new(tool),
            arguments_json: "{}".to_string(),
            arguments_digest: "sha256:test".to_string(),
            run_id: None,
            tool_call_id: "call-1".to_string(),
            provider_id: provider_id(),
            policy_id: policy_id(),
        }
    }

    fn installed(policy: CompiledApplicationToolPolicy) -> Arc<CompiledPolicyProvider> {
        let provider = Arc::new(CompiledPolicyProvider::new(
            provider_id(),
            PolicyProviderGeneration(1),
        ));
        provider.accept(policy).expect("first accept");
        provider
    }

    #[test]
    fn an_exact_member_and_tool_grant_allows() {
        let provider = installed(policy(1, "member-a", "shell"));
        let snapshot = provider.snapshot(&policy_id()).expect("snapshot");
        assert!(matches!(
            snapshot.evaluate(&request("member-a", "shell")),
            ToolConsequenceVerdict::Allow
        ));
    }

    #[test]
    fn a_missing_grant_denies_under_the_artifacts_own_default_deny() {
        let policy = policy(1, "member-a", "shell");
        assert!(
            policy.default_deny,
            "a policy minted through new() must be default-deny"
        );
        let provider = installed(policy);
        let snapshot = provider.snapshot(&policy_id()).expect("snapshot");

        match snapshot.evaluate(&request("member-a", "network")) {
            ToolConsequenceVerdict::Deny(denial) => {
                assert_eq!(denial.code, DENIAL_CODE_NO_GRANT);
                assert!(denial.message.contains("network"), "{}", denial.message);
            }
            other => panic!("expected a typed denial, got {other:?}"),
        }
        // Same tool, wrong member: the grant is per identity, not global.
        assert!(matches!(
            snapshot.evaluate(&request("member-b", "shell")),
            ToolConsequenceVerdict::Deny(_)
        ));
    }

    #[test]
    fn the_provider_refuses_a_revision_rollback() {
        let provider = installed(policy(7, "member-a", "shell"));
        let error = provider
            .accept(policy(6, "member-a", "shell"))
            .expect_err("a lower revision must be refused");
        match error {
            ToolConsequenceFailure::RevisionRollback {
                accepted_revision,
                observed_revision,
                ..
            } => {
                assert_eq!(accepted_revision, 7);
                assert_eq!(observed_revision, 6);
            }
            other => panic!("expected RevisionRollback, got {other:?}"),
        }
        assert_eq!(provider.accepted_revision(&policy_id()), Some(7));
    }

    #[test]
    fn the_provider_refuses_a_reused_revision_whose_content_changed() {
        let provider = installed(policy(7, "member-a", "shell"));
        let error = provider
            .accept(policy(7, "member-a", "network"))
            .expect_err("same revision with different bytes must be refused");
        assert!(
            matches!(
                error,
                ToolConsequenceFailure::RevisionDigestConflict { revision: 7, .. }
            ),
            "expected RevisionDigestConflict, got {error:?}"
        );
        // The accepted snapshot must be unchanged, not partially replaced.
        let snapshot = provider.snapshot(&policy_id()).expect("snapshot");
        assert!(matches!(
            snapshot.evaluate(&request("member-a", "shell")),
            ToolConsequenceVerdict::Allow
        ));
    }

    #[test]
    fn a_forward_revision_replaces_the_snapshot() {
        let provider = installed(policy(1, "member-a", "shell"));
        provider
            .accept(policy(2, "member-a", "network"))
            .expect("a forward revision is accepted");
        assert_eq!(provider.accepted_revision(&policy_id()), Some(2));
        let snapshot = provider.snapshot(&policy_id()).expect("snapshot");
        assert!(matches!(
            snapshot.evaluate(&request("member-a", "network")),
            ToolConsequenceVerdict::Allow
        ));
        assert!(matches!(
            snapshot.evaluate(&request("member-a", "shell")),
            ToolConsequenceVerdict::Deny(_)
        ));
    }

    #[test]
    fn canonical_bytes_install_and_non_canonical_bytes_do_not() {
        let canonical = policy(3, "member-a", "shell")
            .canonical_json()
            .expect("canonical json");
        let provider = Arc::new(CompiledPolicyProvider::new(
            provider_id(),
            PolicyProviderGeneration(1),
        ));
        provider
            .accept_canonical_json(&canonical)
            .expect("canonical bytes install");
        assert_eq!(provider.accepted_revision(&policy_id()), Some(3));

        // Re-serialised without the canonical trailing newline: same policy,
        // non-canonical bytes, and the parse must refuse rather than normalise.
        let mut mangled = canonical;
        assert_eq!(mangled.pop(), Some(b'\n'));
        provider
            .accept_canonical_json(&mangled)
            .expect_err("non-canonical bytes must be refused");
    }

    #[test]
    fn an_unknown_policy_id_is_a_typed_miss() {
        let provider = installed(policy(1, "member-a", "shell"));
        let other = PolicyId::new("not-installed").expect("policy id");
        // `Arc<dyn Snapshot>` is not `Debug`, so `expect_err` cannot be used
        // here; match instead of loosening the trait upstream.
        let error = match provider.snapshot(&other) {
            Ok(_) => panic!("an unknown policy must not resolve"),
            Err(error) => error,
        };
        assert!(
            matches!(error, ToolConsequenceFailure::PolicyMissing { .. }),
            "expected PolicyMissing, got {error:?}"
        );
    }

    fn policy_for(provider: &str, policy: &str, member: &str, tool: &str) -> Vec<u8> {
        CompiledApplicationToolPolicy::new(
            PolicyProviderId::new(provider).expect("provider id"),
            PolicyId::new(policy).expect("policy id"),
            PolicyRevision(1),
            source(),
            grants_for(member, tool),
        )
        .expect("compiled policy should validate")
        .canonical_json()
        .expect("canonical json")
    }

    #[test]
    fn providers_are_derived_from_the_ids_the_artifacts_carry() {
        // HomeCore compiles provider_id "homecore"; nothing in MobKit names it.
        let payloads = vec![
            policy_for("homecore", "household-tools", "member-a", "shell"),
            policy_for("some-other-author", "fleet-baseline", "member-b", "network"),
        ];
        let providers =
            providers_from_canonical_payloads(&payloads).expect("both artifacts install");
        let ids: Vec<&str> = providers
            .iter()
            .map(|provider| provider.provider_id().as_str())
            .collect();
        assert_eq!(ids, vec!["homecore", "some-other-author"]);
    }

    #[test]
    fn a_foreign_provider_id_is_served_rather_than_refused() {
        // This is the bug this function exists to prevent: a gateway that named
        // its own provider refused every policy compiled by anyone else.
        let payloads = vec![policy_for(
            "homecore",
            "household-tools",
            "member-a",
            "shell",
        )];
        let providers = providers_from_canonical_payloads(&payloads).expect("artifact installs");
        assert_eq!(providers.len(), 1);
        let snapshot = providers[0]
            .snapshot(&PolicyId::new("household-tools").expect("policy id"))
            .expect("the carried policy id resolves");
        let mut request = request("member-a", "shell");
        request.provider_id = PolicyProviderId::new("homecore").expect("provider id");
        request.policy_id = PolicyId::new("household-tools").expect("policy id");
        assert!(matches!(
            snapshot.evaluate(&request),
            ToolConsequenceVerdict::Allow
        ));
    }

    #[test]
    fn two_policies_from_one_author_share_a_single_provider() {
        let payloads = vec![
            policy_for("homecore", "household-tools", "member-a", "shell"),
            policy_for("homecore", "guest-tools", "member-b", "network"),
        ];
        let providers = providers_from_canonical_payloads(&payloads).expect("both install");
        assert_eq!(providers.len(), 1, "one author means one provider");
        for policy_id in ["household-tools", "guest-tools"] {
            providers[0]
                .snapshot(&PolicyId::new(policy_id).expect("policy id"))
                .unwrap_or_else(|error| panic!("{policy_id} should resolve: {error}"));
        }
    }

    #[test]
    fn a_registry_built_from_the_derived_providers_binds_the_carried_identity() {
        // The end-to-end path the gateway performs: artifact -> provider ->
        // registry -> bind. bind() additionally validates provenance and
        // consults the supervisor, so passing here proves the composition and
        // not just the pieces.
        let payloads = vec![policy_for(
            "homecore",
            "household-tools",
            "member-a",
            "shell",
        )];
        let derived = providers_from_canonical_payloads(&payloads).expect("artifact installs");
        let providers: Vec<Arc<dyn ToolConsequenceNarrowingPolicy>> = derived
            .into_iter()
            .map(|provider| provider as Arc<dyn ToolConsequenceNarrowingPolicy>)
            .collect();
        let registry = Arc::new(
            ToolConsequencePolicyRegistry::new(
                providers,
                PolicyEvaluationSupervisorConfig::default(),
                None,
            )
            .expect("registry builds from the derived providers"),
        );

        let member = MobMemberBinding {
            mob_id: "mob".to_string(),
            role: "worker".to_string(),
            member: "member-a".to_string(),
        };
        registry
            .bind(
                member.clone(),
                PolicyProviderId::new("homecore").expect("provider id"),
                PolicyId::new("household-tools").expect("policy id"),
            )
            .expect("the carried provider and policy identity must bind");

        // The negative half, which is the shape of the bug this replaces: the
        // registry resolves the CARRIED identity, so a name the artifacts never
        // declared must miss rather than silently bind to whatever is present.
        // `BoundToolConsequencePolicy` is not `Debug`, so match rather than
        // `expect_err`, as elsewhere in this module.
        let error = match registry.bind(
            member,
            PolicyProviderId::new("mobkit-gateway").expect("provider id"),
            PolicyId::new("household-tools").expect("policy id"),
        ) {
            Ok(_) => panic!("a provider no artifact declared must not bind"),
            Err(error) => error,
        };
        assert!(
            matches!(error, ToolConsequenceFailure::ProviderMissing { .. }),
            "expected ProviderMissing, got {error:?}"
        );
    }

    /// One wire fixture, read by BOTH this test and
    /// sdk/python/tests/test_application_tool_policies.py.
    ///
    /// A Rust test and a Python test that each build their own payload can both
    /// pass while disagreeing about the wire, which is how the role-migration
    /// carrier shipped unreachable from the SDK that composes it. Sharing the
    /// bytes is what makes the two sides testable against one another.
    ///
    /// Regenerate with MOBKIT_WRITE_FIXTURE=1; a digest is computed from the
    /// canonical bytes, so the fixture cannot be hand-edited into validity.
    #[test]
    fn the_committed_wire_fixture_installs_its_carried_provider() {
        let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("tests/fixtures/application_tool_policies_init_params.json");
        let canonical = String::from_utf8(policy_for(
            "homecore",
            "household-tools",
            "member-a",
            "shell",
        ))
        .expect("canonical json is utf-8");

        if std::env::var_os("MOBKIT_WRITE_FIXTURE").is_some() {
            let params = serde_json::json!({
                "has_roster_provider": true,
                "application_tool_policies": [canonical],
            });
            let mut rendered = serde_json::to_string_pretty(&params).expect("render fixture");
            rendered.push('\n');
            std::fs::write(&fixture_path, rendered).expect("write fixture");
        }

        let raw = std::fs::read_to_string(&fixture_path).unwrap_or_else(|error| {
            panic!(
                "missing {}: regenerate with MOBKIT_WRITE_FIXTURE=1 ({error})",
                fixture_path.display()
            )
        });
        let params: serde_json::Value = serde_json::from_str(&raw).expect("fixture is valid JSON");
        // Go through the SAME extraction the gateway uses. Reading the key here
        // would test a re-implementation of the parser rather than the parser.
        let payloads = compiled_policy_payloads_from_init_params(&params)
            .expect("the committed wire payload parses through the gateway's own extraction");
        assert_eq!(
            payloads,
            vec![canonical],
            "the committed fixture must carry the canonical bytes this build produces"
        );

        // The point of the fixture is that these exact bytes install and bind.
        let providers = providers_from_canonical_payloads(&payloads)
            .expect("the committed wire payload installs");
        assert_eq!(providers.len(), 1);
        assert_eq!(providers[0].provider_id().as_str(), "homecore");
        providers[0]
            .snapshot(&PolicyId::new("household-tools").expect("policy id"))
            .expect("the carried policy id resolves");
    }

    #[test]
    fn an_absent_parameter_arms_nothing_but_an_explicit_null_is_refused() {
        let absent = serde_json::json!({ "has_roster_provider": true });
        assert!(
            compiled_policy_payloads_from_init_params(&absent)
                .expect("absent is not an error")
                .is_empty()
        );

        for malformed in [
            serde_json::json!({ "application_tool_policies": serde_json::Value::Null }),
            serde_json::json!({ "application_tool_policies": "not-an-array" }),
            serde_json::json!({ "application_tool_policies": [1, 2] }),
            serde_json::json!({ "application_tool_policies": { "a": "b" } }),
        ] {
            let error = compiled_policy_payloads_from_init_params(&malformed)
                .expect_err("a present non-array value must be refused");
            assert!(
                matches!(error, ToolConsequenceFailure::EvaluationFailed { .. }),
                "expected EvaluationFailed, got {error:?}"
            );
        }
    }

    #[test]
    fn a_policy_from_another_provider_is_refused() {
        let provider = Arc::new(CompiledPolicyProvider::new(
            PolicyProviderId::new("mobkit-other").expect("provider id"),
            PolicyProviderGeneration(1),
        ));
        provider
            .accept(policy(1, "member-a", "shell"))
            .expect_err("a policy naming another provider must be refused");
    }
}