polyc-capability 2026.8.1

Capability taxonomy, derivation functions, and the pure gate decision engine for polychrome.
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
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
//! Capability taxonomy, derivation functions, and the pure gate decision
//! engine (`#587`, `#591`).
//!
//! One tool call *requires* a set of [`Capability`]s (derived from its spec's
//! MCP-style annotations plus its registry provenance) and is *granted* a set
//! (derived from the agent's policy plus the conversation's provenance/taint
//! state at that moment). [`decide`] compares them and returns the single
//! [`GateOutcome`] for the call — the one decision path that replaces the
//! previous OR of an argument-aware policy check, a sandbox-denial escalation,
//! and a runtime "untrusted content + egress" override.
//!
//! The containment invariants live here as pure logic, testable exhaustively:
//!
//! - Untrusted content in context removes [`Capability::ArbitraryEgress`]
//!   **and** [`Capability::MutateExternal`] from the granted set — a message
//!   body or an issue title carries attacker-steered bytes out as surely as a
//!   fetch does.
//! - An operator-declared taint-resilient set survives that subtraction, and
//!   only when the policy declares it ([`GrantPolicy::taint_resilient`]).
//! - A tool whose spec cannot be classified requires the full privileged set
//!   ([`CapabilitySet::all`]) — fail closed.
//! - The model is monotonic: under a fixed policy, adding taint never adds a
//!   capability.
//!
//! Everything here is a pure function over its inputs. No gate wiring, no IO,
//! no clock: the executor surface (`#592`) derives the inputs and the agent's
//! per-call gate (`#593`) is a thin adapter over [`decide`].

use polyc_llm::ToolSpec;

// ── Capability + set ─────────────────────────────────────────────────────────

/// One thing a tool call can do — the unit of the containment model.
///
/// The taxonomy is deliberately small and rarely changes. Adding a member
/// means extending this enum and the two derivation functions
/// ([`required_capabilities`], [`granted_capabilities`]); the decision engine
/// ([`decide`]) operates on sets generically and never needs to change (a
/// pinned test demonstrates this).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub enum Capability {
    /// Read state confined to the conversation's sandbox (workspace files).
    LocalRead = 1,
    /// Mutate state confined to the conversation's sandbox (workspace writes,
    /// sandboxed shell). Destructive *inside the box* is still local.
    LocalWrite = 1 << 1,
    /// Call an operator-registered connector endpoint (or a first-party
    /// control-plane service) — a fixed destination the operator vouched for,
    /// carrying only model-authored arguments. Taint never revokes this.
    FixedConnectorRead = 1 << 2,
    /// Send bytes to a model-controlled external destination — the built-in
    /// web/paid fetchers. The classic exfiltration channel.
    ArbitraryEgress = 1 << 3,
    /// Perform a side effect outside the sandbox: mutate external state,
    /// send a message, file an issue, spend money. An external mutation
    /// carries model-authored bytes to destinations an attacker may read,
    /// so it is an egress channel in effect even when the destination is
    /// fixed.
    MutateExternal = 1 << 4,
    /// Grant a third party access to the system itself — the admin invite
    /// (`#700`). Deliberately held OUT of [`Self::ALL`], so
    /// it is never in [`CapabilitySet::all`], never in the default grant, and —
    /// because [`Self::from_name`] only recognizes members of [`Self::ALL`] —
    /// unnameable in operator config: no policy or wire input can ever seed it
    /// into a granted set. A tool that requires it therefore always exceeds its
    /// granted set and always escalates to a human, in every taint state and
    /// policy mode. This is the structural mechanism behind "an access-grant is
    /// never autonomous — a person always confirms the exact invitee".
    GrantAccess = 1 << 5,
    /// Remove a third party's access to the system itself — the admin
    /// de-admission (`#713`), the offboarding sibling of [`Self::GrantAccess`].
    /// Held OUT of [`Self::ALL`] for the identical reason: never in
    /// [`CapabilitySet::all`], never in the default grant, and unnameable in
    /// operator config ([`Self::from_name`] only recognizes [`Self::ALL`]
    /// members), so a tool requiring it always exceeds its granted set and
    /// always escalates to a human, in every taint state and policy mode. This
    /// is the structural mechanism behind "a removal is never autonomous — a
    /// person always confirms the exact person being removed".
    RevokeAccess = 1 << 6,
    /// Take away a persona's ADMIN ROLE — the `demote` tool (`#715`), and the
    /// sibling that completes the admin-management set alongside
    /// [`Self::GrantAccess`]/[`Self::RevokeAccess`]. Held OUT of [`Self::ALL`]
    /// for the identical reason: never in [`CapabilitySet::all`], never in the
    /// default grant, and unnameable in operator config ([`Self::from_name`]
    /// only recognizes [`Self::ALL`] members), so a tool requiring it always
    /// exceeds its granted set and always escalates to a human, in every
    /// taint state and policy mode. This is the structural mechanism behind
    /// "an admin's role is never removed autonomously — a person always
    /// confirms exactly whose role is being taken away".
    ///
    /// This occupies the last bit `u8` can hold (`1 << 7`); the NEXT
    /// never-granted marker added to this taxonomy needs `CapabilitySet` (and
    /// this enum's `#[repr]`) widened from `u8` to `u16` first.
    ManageAdmin = 1 << 7,
}

impl Capability {
    /// Every *grantable* member of the taxonomy, in declaration order.
    ///
    /// [`Self::GrantAccess`] and [`Self::RevokeAccess`] are deliberately
    /// absent: they are the never-granted markers (see their docs), so they
    /// are excluded from [`CapabilitySet::all`], the default grant, name
    /// parsing ([`Self::from_name`]), and set iteration — everything driven
    /// off this array operates only over the grantable set.
    pub const ALL: [Self; 5] = [
        Self::LocalRead,
        Self::LocalWrite,
        Self::FixedConnectorRead,
        Self::ArbitraryEgress,
        Self::MutateExternal,
    ];

    /// Stable kebab-case name, used on the wire (the harness turn input), in
    /// the Agent custom resource's taint-resilient list, and as a telemetry
    /// label. Inverse of [`Self::from_name`].
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::LocalRead => "local-read",
            Self::LocalWrite => "local-write",
            Self::FixedConnectorRead => "fixed-connector-read",
            Self::ArbitraryEgress => "arbitrary-egress",
            Self::MutateExternal => "mutate-external",
            Self::GrantAccess => "grant-access",
            Self::RevokeAccess => "revoke-access",
            Self::ManageAdmin => "manage-admin",
        }
    }

    /// Parse a stable kebab-case name; `None` for anything unrecognized so a
    /// caller reading operator config fails toward granting nothing.
    #[must_use]
    pub fn from_name(name: &str) -> Option<Self> {
        Self::ALL.into_iter().find(|c| c.as_str() == name)
    }
}

/// A set of [`Capability`]s. Small, `Copy`, and closed under the usual set
/// algebra — the decision engine works only through these operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
pub struct CapabilitySet(u8);

/// Scoping/audit name for the LLM provider's native web-search-grounding
/// primitive (issue `#1226`).
///
/// A request-level flag the provider turns into its own built-in search tool
/// entry mid-generation, never a model-invoked `tool_use` call. Lives here
/// (rather than `polyc_tools`, where every other tool name lives) because
/// both `polyc_agent` (the per-step gate that consults it) and `polyc_tools`
/// (the `builtinTools` scoping check) need the same literal and neither crate
/// may depend on the other.
pub const NATIVE_SEARCH_GROUNDING: &str = "web_search_grounding";

impl CapabilitySet {
    /// The empty set.
    pub const EMPTY: Self = Self(0);

    /// The capabilities the provider's native web-search-grounding primitive
    /// ([`NATIVE_SEARCH_GROUNDING`]) requires.
    ///
    /// The classic exfiltration channel, the same floor as the built-in web
    /// fetchers (`web_fetch`, `paid_fetch`). Unlike every other tool, this
    /// primitive is never a [`polyc_llm::ToolSpec`] the model calls
    /// explicitly, so there is no per-call `tool_use` for the ordinary
    /// classification path (`required_capabilities`) to inspect — the call
    /// site that decides whether to turn grounding on for a step compares
    /// this constant against [`granted_capabilities`] directly, via the same
    /// [`decide`] every real tool call goes through.
    #[must_use]
    pub const fn native_search_grounding_requirements() -> Self {
        Self::of(Capability::ArbitraryEgress)
    }

    /// The full privileged set — every member of the taxonomy. This is the
    /// fail-closed requirement for an unclassifiable tool.
    #[must_use]
    pub const fn all() -> Self {
        let mut bits = 0u8;
        let mut i = 0;
        while i < Capability::ALL.len() {
            bits |= Capability::ALL[i] as u8;
            i += 1;
        }
        Self(bits)
    }

    /// The set containing exactly `capability`.
    #[must_use]
    pub const fn of(capability: Capability) -> Self {
        Self(capability as u8)
    }

    /// This set plus `capability`.
    #[must_use]
    pub const fn with(self, capability: Capability) -> Self {
        Self(self.0 | capability as u8)
    }

    /// Whether `capability` is in this set.
    #[must_use]
    pub const fn contains(self, capability: Capability) -> bool {
        self.0 & capability as u8 != 0
    }

    /// Whether this set has no members.
    #[must_use]
    pub const fn is_empty(self) -> bool {
        self.0 == 0
    }

    /// Whether every member of this set is also in `other`.
    #[must_use]
    pub const fn is_subset_of(self, other: Self) -> bool {
        self.0 & !other.0 == 0
    }

    /// Set union.
    #[must_use]
    pub const fn union(self, other: Self) -> Self {
        Self(self.0 | other.0)
    }

    /// Set intersection.
    #[must_use]
    pub const fn intersection(self, other: Self) -> Self {
        Self(self.0 & other.0)
    }

    /// Set difference: the members of this set that are not in `other`.
    #[must_use]
    pub const fn difference(self, other: Self) -> Self {
        Self(self.0 & !other.0)
    }

    /// The members of this set, in [`Capability::ALL`] order.
    pub fn iter(self) -> impl Iterator<Item = Capability> {
        Capability::ALL
            .into_iter()
            .filter(move |c| self.contains(*c))
    }

    /// Build a set from stable kebab-case names (e.g. an Agent custom
    /// resource's taint-resilient list or the harness wire). Unrecognized
    /// names are NOT granted — they are returned separately so the caller can
    /// log them — which is the fail-closed direction: a typo in operator
    /// config grants nothing rather than something unintended.
    pub fn from_names<'a, I: IntoIterator<Item = &'a str>>(names: I) -> (Self, Vec<String>) {
        let mut set = Self::EMPTY;
        let mut unknown = Vec::new();
        for name in names {
            match Capability::from_name(name) {
                Some(c) => set = set.with(c),
                None => unknown.push(name.to_owned()),
            }
        }
        (set, unknown)
    }

    /// The stable kebab-case names of this set's members, in
    /// [`Capability::ALL`] order — the inverse of [`Self::from_names`].
    #[must_use]
    pub fn names(self) -> Vec<&'static str> {
        self.iter().map(Capability::as_str).collect()
    }
}

impl FromIterator<Capability> for CapabilitySet {
    fn from_iter<I: IntoIterator<Item = Capability>>(iter: I) -> Self {
        iter.into_iter().fold(Self::EMPTY, Self::with)
    }
}

/// The capabilities that untrusted content in context revokes: both channels
/// that carry model-authored bytes to destinations an attacker may read.
///
/// This is the single, tested home of the containment rule that used to be
/// the "lethal trifecta override": [`Capability::ArbitraryEgress`] (a fetch to
/// a model-chosen destination) **and** [`Capability::MutateExternal`] (a
/// message body or issue field is an exfiltration channel the egress rule
/// alone would miss).
pub const TAINT_REVOKED: CapabilitySet =
    CapabilitySet::of(Capability::ArbitraryEgress).with(Capability::MutateExternal);

// ── Requirement derivation ────────────────────────────────────────────────────

/// Where a tool comes from — the registry-provenance half of classification.
///
/// Trust scoping is the security-load-bearing part: taint-immune
/// classification ([`Capability::FixedConnectorRead`]) is earned only by
/// operator registration ([`ToolOrigin::RegisteredConnector`] /
/// [`ToolOrigin::FirstParty`]) — never by a connector's self-declared
/// annotation hints alone. This is what the MCP specification normatively
/// requires: clients MUST treat tool annotations as untrusted unless the
/// server is trusted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolOrigin {
    /// A sandbox-confined built-in (the coding tools): runs inside the
    /// conversation's execution unit, which has no outbound network.
    LocalSandbox,
    /// A built-in fetcher (the web/paid fetchers): brokered on the trusted
    /// side to a model-controlled destination.
    Fetcher,
    /// A built-in that reads or acts on the caller's own first-party state via
    /// the control plane (the history and wallet families): a fixed,
    /// operator-owned destination.
    FirstParty,
    /// A built-in that grants a third party access to the system itself — the
    /// admin invite (`#700`). Classified apart from [`Self::FirstParty`]
    /// because it requires [`Capability::GrantAccess`], the never-granted
    /// marker, so it always escalates to a human before anything is minted: the
    /// agent can only ever PROPOSE an invite, never grant access on its own.
    AccessGrant,
    /// A built-in that removes a third party's access to the system itself —
    /// the admin de-admission (`#713`), the offboarding sibling of
    /// [`Self::AccessGrant`]. Requires [`Capability::RevokeAccess`], the
    /// never-granted marker, so it always escalates to a human before anything
    /// is removed: the agent can only ever PROPOSE a removal, never de-admit
    /// anyone on its own.
    AccessRevoke,
    /// A built-in that takes away a persona's ADMIN ROLE — the `demote`
    /// tool (`#715`), the sibling that completes the admin-management set
    /// alongside [`Self::AccessGrant`]/[`Self::AccessRevoke`]. Requires
    /// [`Capability::ManageAdmin`], the never-granted marker, so it always
    /// escalates to a human before anyone's admin role changes: the agent
    /// can only ever PROPOSE a demote, never remove anyone's admin role on
    /// its own.
    AdminManage,
    /// A connector tool whose server the operator registered (registry
    /// provenance, e.g. the `ToolService` registry). Its annotations are
    /// load-bearing inputs because the operator vouched for the server.
    RegisteredConnector,
    /// Anything else: an unregistered server's self-declared tool, an unknown
    /// name, an unannotated spec. Fails closed to the privileged set.
    Unknown,
}

/// The classification inputs for one tool: its spec's MCP-style annotations
/// plus its registry provenance.
///
/// Built by the executor surface (`#592`) — [`ToolProfile::for_spec`] reads
/// the annotations off the spec, and the executor supplies the origin from
/// what it knows about the tool's source.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ToolProfile {
    /// Registry provenance — see [`ToolOrigin`].
    pub origin: ToolOrigin,
    /// MCP `readOnlyHint`: the tool does not modify its environment.
    pub read_only: bool,
    /// MCP `destructiveHint`: the tool may perform irreversible or
    /// side-effecting changes.
    pub destructive: bool,
    /// MCP `openWorldHint`: the tool's RESULT may carry content of
    /// uncontrolled provenance. An ingestion-source property: it drives
    /// taint, not the required set (a registered connector's open-world read
    /// still dials only its fixed, operator-vouched endpoint).
    pub open_world: bool,
}

impl ToolProfile {
    /// Read the classification annotations off `spec`, with the
    /// executor-supplied registry provenance.
    #[must_use]
    pub const fn for_spec(spec: &ToolSpec, origin: ToolOrigin) -> Self {
        Self {
            origin,
            read_only: spec.read_only,
            destructive: spec.destructive,
            open_world: spec.open_world,
        }
    }
}

/// Derive the capabilities a tool call requires from its profile — the
/// spec's existing annotations plus registry provenance. No new hand-written
/// per-tool metadata.
///
/// The mapping (see the `#587` design):
///
/// - sandbox-confined built-in ⇒ local read (+ local write unless read-only —
///   destructive *inside the box* is still local);
/// - built-in fetcher ⇒ arbitrary egress (+ external mutation when
///   destructive, e.g. a paying fetch);
/// - first-party / operator-registered connector ⇒ fixed-connector read
///   (+ external mutation unless read-only and non-destructive);
/// - access-grant (the admin invite) ⇒ the never-granted
///   [`Capability::GrantAccess`], so it always escalates to a human;
/// - unknown / unclassifiable ⇒ the full privileged set, fail closed: an
///   unknown tool never slips through un-gated.
///
/// `open_world` is deliberately not consulted: it marks an ingestion source
/// (drives taint when the result enters context), not an outbound
/// capability.
#[must_use]
pub const fn required_capabilities(profile: ToolProfile) -> CapabilitySet {
    match profile.origin {
        ToolOrigin::LocalSandbox => {
            if profile.read_only {
                CapabilitySet::of(Capability::LocalRead)
            } else {
                CapabilitySet::of(Capability::LocalRead).with(Capability::LocalWrite)
            }
        }
        ToolOrigin::Fetcher => {
            if profile.destructive {
                CapabilitySet::of(Capability::ArbitraryEgress).with(Capability::MutateExternal)
            } else {
                CapabilitySet::of(Capability::ArbitraryEgress)
            }
        }
        ToolOrigin::FirstParty | ToolOrigin::RegisteredConnector => {
            if profile.read_only && !profile.destructive {
                CapabilitySet::of(Capability::FixedConnectorRead)
            } else {
                CapabilitySet::of(Capability::FixedConnectorRead).with(Capability::MutateExternal)
            }
        }
        // The admin invite: it requires only the never-granted
        // `GrantAccess`, so the gate escalates it in EVERY taint state and
        // policy mode. The annotations are not consulted — proposing an
        // access-grant always needs a person, regardless of how the tool
        // declares itself. The actual mint's first-party mutation is enforced
        // control-plane-side, after approval, not modeled as the agent call's
        // granted capability.
        ToolOrigin::AccessGrant => CapabilitySet::of(Capability::GrantAccess),
        // The admin de-admission (#713): the offboarding sibling of the admin
        // invite above — same reasoning, same never-granted-marker mechanism.
        ToolOrigin::AccessRevoke => CapabilitySet::of(Capability::RevokeAccess),
        // The admin demote (#715): completes the admin-management set —
        // same reasoning, same never-granted-marker mechanism.
        ToolOrigin::AdminManage => CapabilitySet::of(Capability::ManageAdmin),
        ToolOrigin::Unknown => CapabilitySet::all(),
    }
}

/// Clamp a re-declared profile so a connector's runtime annotation change can
/// only ever ADD required capabilities (`#598`).
///
/// A connector may re-declare its tools mid-conversation (list-changed). A
/// re-declaration never removes a requirement and never earns taint-immunity
/// at runtime: the merged profile keeps the *less* trusted value of each
/// annotation (loses `read_only` if either side lost it, keeps `destructive`
/// and `open_world` if either side had it). The origin is NOT an input from
/// the re-declaration at all — registry provenance is an operator act the
/// executor derives, never something a connector can assert about itself —
/// so the merged profile keeps the origin the conversation started with.
/// The result is pinned monotonic by test:
/// `required_capabilities(monotonic_redeclaration(old, new))` is always a
/// superset of `required_capabilities(old)`.
#[must_use]
pub const fn monotonic_redeclaration(old: ToolProfile, new: ToolProfile) -> ToolProfile {
    ToolProfile {
        origin: old.origin,
        read_only: old.read_only && new.read_only,
        destructive: old.destructive || new.destructive,
        open_world: old.open_world || new.open_world,
    }
}

// ── Deployment viability (#1415) ────────────────────────────────────────────────

/// A browser-facing ceremony page a built-in mints a one-time link to.
///
/// Closed and small on purpose: only ceremonies that actually gate a
/// chat-callable built-in belong here. `wallet-signup` (new-account creation)
/// and `wallet-passkey-login` (the web app's own sign-in) are real deployment
/// ceremonies too, but neither one backs any built-in tool call — they are
/// pure browser flows a person reaches directly, never something the model
/// mints a link to — so adding them here would leave a [`Requirement`]
/// variant no built-in ever resolves to, which the harness's dead-variant
/// guard exists to catch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Ceremony {
    /// The wallet-link card (`POLYCHROME_WALLET_LINK_URL`): mints the link a
    /// caller signs to attach an external wallet, and the shared page the
    /// spending-limit-update and hard-revoke ceremonies reuse.
    WalletLink,
    /// The email-verification magic-link page (`POLYCHROME_EMAIL_LINK_URL`):
    /// mints the link a caller clicks to verify an email address.
    EmailMagicLink,
}

/// A deployment prerequisite a built-in's calls depend on.
///
/// A closed, exhaustively matchable taxonomy of the STATIC, versioned facts a
/// built-in family needs configured before any call of its own can do
/// anything, sibling to [`ToolOrigin`] in the same "static shape of the
/// built-in surface" sense. Carries only the fact, never remedy text: the
/// copy a person reads about a missing prerequisite belongs to the surface
/// that renders it (an approval card, a status tool), not to this taxonomy.
///
/// Extending the taxonomy means adding a variant here and teaching
/// `polyc_tools::capability::builtin_requirements` (`crates/tools`) which
/// built-ins need it, and [`DeploymentCapabilities::is_viable`] which
/// deployment fact answers it — the harness's `builtin_surface_guard` fails
/// closed if either side is left out (a requirement no built-in resolves to,
/// or a built-in a requirement can't classify).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Requirement {
    /// A usable outbound mail relay (credentials/token valid, not merely a
    /// relay URL set — a missing URL alone falls back to a hosted default).
    /// `link_email` needs this to actually deliver a verification message.
    MailRelay,
    /// A configured browser-facing ceremony page — see [`Ceremony`].
    CeremonyPage(Ceremony),
    /// The outbound-payments proxy: a settlement currency and a way to sign
    /// (a deployment env signer or a caller's own delegated wallet key).
    /// `paid_fetch` needs this to ever send a payment.
    PaymentsProxy,
    /// The persona-credential (mandate-signing passkey) enrollment ceremony
    /// — what the persona passkey `persona_credential_setup` mints actually
    /// buys: the ability for a later signature to authorize spend on the
    /// caller's behalf.
    MandateIssuer,
}

impl Requirement {
    /// Every concrete [`Requirement`] value, in declaration order — the
    /// enumeration [`DeploymentCapabilities::viable_names`] and
    /// [`Self::from_name`] iterate over, and the harness guard's
    /// dead-variant check walks.
    pub const ALL: [Self; 5] = [
        Self::MailRelay,
        Self::CeremonyPage(Ceremony::WalletLink),
        Self::CeremonyPage(Ceremony::EmailMagicLink),
        Self::PaymentsProxy,
        Self::MandateIssuer,
    ];

    /// Stable kebab-case name, used on the wire
    /// (`TurnInput.viable_requirements`) and as a telemetry label. Inverse of
    /// [`Self::from_name`].
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::MailRelay => "mail-relay",
            Self::CeremonyPage(Ceremony::WalletLink) => "ceremony-page:wallet-link",
            Self::CeremonyPage(Ceremony::EmailMagicLink) => "ceremony-page:email-magic-link",
            Self::PaymentsProxy => "payments-proxy",
            Self::MandateIssuer => "mandate-issuer",
        }
    }

    /// Parse a stable kebab-case name; `None` for anything unrecognized so a
    /// caller reading the wire fails toward treating the requirement as
    /// unmet rather than guessing.
    #[must_use]
    pub fn from_name(name: &str) -> Option<Self> {
        Self::ALL.into_iter().find(|r| r.as_str() == name)
    }
}

/// The deployment-configuration facts [`Requirement`]s are checked against.
///
/// Resolved ONCE at control-plane startup from the same `Option`/`Arc`
/// configuration each ceremony/proxy already builds for its own use (never a
/// second, independent env read that could drift from what the ceremony
/// itself decided), mirroring `ConfiguredBackends` in the harness's
/// `main.rs`: a struct of independent configuration facts, not states of one
/// state machine — hence the flat bools below rather than a nested
/// enum/state-machine shape.
///
/// Carried across the control-plane/harness wire as
/// `TurnInput.viable_requirements` (the [`Requirement::as_str`] names this
/// deployment satisfies) because the facts live in control-plane-only
/// configuration (mail relay credentials, ceremony URLs, the payments
/// signer) the harness sandbox cannot read for itself — the harness never
/// resolves this struct locally; it reconstructs the viable subset with
/// [`Self::from_names`] from the wire strings each turn.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[allow(clippy::struct_excessive_bools)] // five INDEPENDENT config facts (see doc above), not a state machine
pub struct DeploymentCapabilities {
    /// [`Requirement::MailRelay`] is met.
    pub mail_relay: bool,
    /// <code>[Requirement::CeremonyPage]([Ceremony::WalletLink])</code> is met.
    pub wallet_link_ceremony: bool,
    /// <code>[Requirement::CeremonyPage]([Ceremony::EmailMagicLink])</code> is met.
    pub email_magic_link_ceremony: bool,
    /// [`Requirement::PaymentsProxy`] is met.
    pub payments_proxy: bool,
    /// [`Requirement::MandateIssuer`] is met.
    pub mandate_issuer: bool,
}

impl DeploymentCapabilities {
    /// Every [`Requirement`] viable — the standalone/dev/test posture, and
    /// the explicit choice a caller that genuinely does not care about
    /// deployment viability (a test scoping only `builtin_allow`, the
    /// in-process whole-conversation test transport) reaches for by name
    /// instead of hand-listing every field `true`. NOT the type's
    /// [`Default`] — [`Default`] stays fail-closed (nothing viable), so a
    /// caller that forgets to thread the real wire-resolved value hides
    /// every requirement-gated built-in instead of silently over-advertising
    /// one whose deployment prerequisite is actually unmet.
    #[must_use]
    pub const fn all() -> Self {
        Self {
            mail_relay: true,
            wallet_link_ceremony: true,
            email_magic_link_ceremony: true,
            payments_proxy: true,
            mandate_issuer: true,
        }
    }

    /// Whether this deployment currently satisfies `requirement`.
    #[must_use]
    pub const fn is_viable(self, requirement: Requirement) -> bool {
        match requirement {
            Requirement::MailRelay => self.mail_relay,
            Requirement::CeremonyPage(Ceremony::WalletLink) => self.wallet_link_ceremony,
            Requirement::CeremonyPage(Ceremony::EmailMagicLink) => self.email_magic_link_ceremony,
            Requirement::PaymentsProxy => self.payments_proxy,
            Requirement::MandateIssuer => self.mandate_issuer,
        }
    }

    /// Whether every member of `requirements` is viable — the join
    /// `build_tool_executor` folds into `granted ∩ owned ∩ viable`. An empty
    /// slice (a built-in with no deployment prerequisite) is always viable.
    #[must_use]
    pub fn all_viable(self, requirements: &[Requirement]) -> bool {
        requirements.iter().all(|r| self.is_viable(*r))
    }

    /// The stable kebab-case names of every [`Requirement`] this deployment
    /// satisfies, in [`Requirement::ALL`] order — what the control plane
    /// puts on the wire. Inverse of [`Self::from_names`].
    #[must_use]
    pub fn viable_names(self) -> Vec<&'static str> {
        Requirement::ALL
            .into_iter()
            .filter(|r| self.is_viable(*r))
            .map(Requirement::as_str)
            .collect()
    }

    /// Reconstruct from the wire's stable-name list (`TurnInput.viable_requirements`)
    /// — the harness's side of [`Self::viable_names`]. An unrecognized name
    /// (a newer control plane's requirement an older harness doesn't know)
    /// is silently ignored rather than failing the turn: an unknown
    /// requirement can never be satisfied by an older binary's `is_viable`
    /// match anyway, so any built-in that needs it stays hidden either way.
    #[must_use]
    pub fn from_names<'a, I: IntoIterator<Item = &'a str>>(names: I) -> Self {
        let mut caps = Self::default();
        for name in names {
            match Requirement::from_name(name) {
                Some(Requirement::MailRelay) => caps.mail_relay = true,
                Some(Requirement::CeremonyPage(Ceremony::WalletLink)) => {
                    caps.wallet_link_ceremony = true;
                }
                Some(Requirement::CeremonyPage(Ceremony::EmailMagicLink)) => {
                    caps.email_magic_link_ceremony = true;
                }
                Some(Requirement::PaymentsProxy) => caps.payments_proxy = true,
                Some(Requirement::MandateIssuer) => caps.mandate_issuer = true,
                None => {}
            }
        }
        caps
    }
}

// ── Grant derivation ──────────────────────────────────────────────────────────

/// Whether untrusted content is in the conversation's context at this gate
/// decision.
///
/// The provenance (taint) input to grant derivation, computed from the
/// durable seed OR the live transcript scan, never from the turn's own
/// output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TaintState {
    /// No untrusted content in context.
    #[default]
    Clean,
    /// Untrusted content is in context: the granted set loses
    /// [`TAINT_REVOKED`] except what the policy declares taint-resilient.
    Tainted,
}

/// The agent's configured capability policy — the operator-controlled half of
/// grant derivation.
///
/// Both fields come from cluster config (the Agent custom resource),
/// unreachable from within a conversation or the request path.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GrantPolicy {
    /// The capabilities the agent's policy grants on a clean context.
    pub base: CapabilitySet,
    /// Operator-declared capabilities that survive the taint subtraction for
    /// this agent's conversations (the generalization of the trusted-egress
    /// flag: how an unattended routine keeps posting after it reads).
    /// Default empty. Never grants beyond [`Self::base`].
    pub taint_resilient: CapabilitySet,
}

impl Default for GrantPolicy {
    /// Grant everything on a clean context, nothing taint-resilient — the
    /// posture of every conversation without an operator declaration.
    fn default() -> Self {
        Self {
            base: CapabilitySet::all(),
            taint_resilient: CapabilitySet::EMPTY,
        }
    }
}

/// Derive the capabilities granted to one gate decision from the agent's
/// policy and the provenance state **at that moment**.
///
/// Recomputed per call, because taint can enter mid-turn and must revoke for
/// the very next call.
///
/// The containment rule: taint present ⇒ [`TAINT_REVOKED`] (arbitrary egress
/// AND external mutation) removed from the granted set; the operator-declared
/// taint-resilient set survives the subtraction, but never grants beyond the
/// base. Monotonic under a fixed policy: adding taint never adds a
/// capability (pinned by test).
#[must_use]
pub const fn granted_capabilities(policy: GrantPolicy, taint: TaintState) -> CapabilitySet {
    match taint {
        TaintState::Clean => policy.base,
        TaintState::Tainted => {
            let survivors = policy.taint_resilient.intersection(TAINT_REVOKED);
            policy
                .base
                .difference(TAINT_REVOKED)
                .union(policy.base.intersection(survivors))
        }
    }
}

// ── Decision engine ───────────────────────────────────────────────────────────

/// An argument transform the argument-aware dispatch policy asked for, honored
/// only when the call is otherwise allowed.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ArgTransform {
    /// Run the call as proposed.
    #[default]
    None,
    /// Run the call with these replacement arguments.
    Rewrite(String),
    /// Prepend this context as an internal-only note before the call runs.
    InjectContext(String),
}

/// The per-call policy verdicts [`decide`] folds into the one outcome.
///
/// Carries the argument-aware dispatch decision plus the sandbox-escalation
/// check, already evaluated against the call's arguments by the caller.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CallPolicy {
    /// A hard policy veto with its reason: the call is blocked without a
    /// human prompt and a human approval cannot satisfy it. Takes precedence
    /// over everything else.
    pub veto: Option<String>,
    /// The argument-aware policy (or the tool's intrinsic gate) demands a
    /// human decision for this call regardless of capabilities.
    pub requires_human: bool,
    /// The sandbox would deny this call before any side effect and the
    /// deployment escalates such denials to a human instead of running into
    /// the flat denial.
    pub sandbox_escalation: bool,
    /// The argument transform to honor when the call is allowed.
    pub transform: ArgTransform,
}

/// The single unified gate result for one tool call — replaces both the
/// argument-aware `ToolDecision` and the `needs_approval`/override booleans.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GateOutcome {
    /// Execute the call as proposed.
    Allow,
    /// Execute the call with rewritten arguments.
    Modify(
        /// The replacement arguments (JSON).
        String,
    ),
    /// Prepend this context as an internal-only note, then execute.
    InjectContext(
        /// The note text.
        String,
    ),
    /// Pause for a human decision.
    Escalate {
        /// Why, in plain language for the approval card. Empty for an
        /// ordinary policy/sandbox gate (the edge renders its default
        /// prompt); non-empty when capabilities are missing.
        reason: String,
        /// The required capabilities the call's granted set does not cover.
        /// Empty when the escalation is a policy/sandbox gate rather than a
        /// capability shortfall.
        missing: CapabilitySet,
    },
    /// Block the call without a human prompt; the reason is surfaced to the
    /// model as the tool result so it can adapt.
    Deny(
        /// The policy's reason.
        String,
    ),
}

impl GateOutcome {
    /// Stable lowercase label for telemetry — one counter per gate outcome.
    #[must_use]
    pub const fn label(&self) -> &'static str {
        match self {
            Self::Allow => "allow",
            Self::Modify(_) => "modify",
            Self::InjectContext(_) => "inject_context",
            Self::Escalate { .. } => "escalate",
            Self::Deny(_) => "deny",
        }
    }
}

/// The one pure decision: compare what the call requires with what it is
/// granted, under the argument-aware policy verdicts, and return the single
/// [`GateOutcome`].
///
/// Precedence, pinned by test:
///
/// 1. hard policy veto ⇒ [`GateOutcome::Deny`];
/// 2. required ⊄ granted ⇒ [`GateOutcome::Escalate`] carrying the missing
///    set and a plain-language reason;
/// 3. the policy demands a human (argument-aware gate or sandbox-denial
///    escalation) ⇒ [`GateOutcome::Escalate`] with an empty missing set;
/// 4. otherwise honor the argument transform or allow.
///
/// The engine is pure set algebra over the capability sets — it never
/// matches on a specific [`Capability`], so extending the taxonomy requires
/// no change here (pinned by test).
#[must_use]
pub fn decide(
    required: CapabilitySet,
    granted: CapabilitySet,
    policy: &CallPolicy,
    tool_name: &str,
) -> GateOutcome {
    if let Some(reason) = &policy.veto {
        return GateOutcome::Deny(reason.clone());
    }
    let missing = required.difference(granted);
    if !missing.is_empty() {
        return GateOutcome::Escalate {
            reason: escalation_reason(tool_name, missing),
            missing,
        };
    }
    if policy.requires_human || policy.sandbox_escalation {
        return GateOutcome::Escalate {
            reason: String::new(),
            missing: CapabilitySet::EMPTY,
        };
    }
    match &policy.transform {
        ArgTransform::None => GateOutcome::Allow,
        ArgTransform::Rewrite(args) => GateOutcome::Modify(args.clone()),
        ArgTransform::InjectContext(note) => GateOutcome::InjectContext(note.clone()),
    }
}

/// The plain-language reason for a missing-capability escalation, rendered
/// verbatim on the approval card on every edge (one shared helper so the
/// wording never differs by surface).
///
/// User-facing copy: no internal terms, active sentences, honest about risk
/// without overclaiming. In the current model a capability is only ever
/// missing because untrusted content entered the conversation (the base
/// policy grants everything), so the copy names that cause; a future
/// narrowed base policy reuses the same wording — the access is missing
/// either way, and the approver's decision is the same.
///
/// The `MutateExternal`-missing arms in particular name a possibility being
/// checked, not a fact about the call: the gate has no way to confirm a call
/// is read-only here (an unannotated registered tool, or any unregistered
/// tool falling back to [`CapabilitySet::all`], lands on this arm whether or
/// not it ever changes anything), so the copy says the check runs before the
/// tool "could" reach out or mutate, never that it will.
#[must_use]
pub fn escalation_reason(tool_name: &str, missing: CapabilitySet) -> String {
    // The access-grant marker takes precedence: an invite always needs a person
    // to confirm the exact invitee, in every conversation state, so the wording
    // is about the grant itself, not about any content the conversation took in.
    if missing.contains(Capability::GrantAccess) {
        return format!(
            "`{tool_name}` would give someone access to Polychrome, so a person needs to \
             confirm exactly who's being invited before it goes ahead"
        );
    }
    // The revoke-access marker takes the same precedence, for the same reason:
    // removing someone's access always needs a person to confirm exactly who,
    // in every conversation state.
    if missing.contains(Capability::RevokeAccess) {
        return format!(
            "`{tool_name}` would remove someone's access to Polychrome, so a person needs to \
             confirm exactly who's being removed before it goes ahead"
        );
    }
    // The manage-admin marker takes the same precedence: taking away someone's
    // admin role always needs a person to confirm exactly whose, in every
    // conversation state.
    if missing.contains(Capability::ManageAdmin) {
        return format!(
            "`{tool_name}` would take away someone's admin role, so a person needs to confirm \
             exactly whose role is being removed before it goes ahead"
        );
    }
    let reaches_out = missing.contains(Capability::ArbitraryEgress);
    let mutates = missing.contains(Capability::MutateExternal);
    match (reaches_out, mutates) {
        // Missing `MutateExternal` does not mean `tool_name` mutates — see
        // this function's doc comment for why.
        (true, true) => format!(
            "this conversation has taken in content from outside sources, so `{tool_name}` \
             needs a quick human check before it could send anything out or change anything \
             beyond this conversation"
        ),
        (true, false) => format!(
            "this conversation has taken in content from outside sources, so `{tool_name}` \
             needs a quick human check before it reaches an outside address"
        ),
        (false, true) => format!(
            "this conversation has taken in content from outside sources, so `{tool_name}` \
             needs a quick human check before it could change anything beyond this conversation"
        ),
        (false, false) => format!(
            "`{tool_name}` needs more access than this conversation currently has, so a \
             human check is needed first"
        ),
    }
}

#[cfg(test)]
mod tests;