polyc-query-credential 2026.10.1

Credential verification and query scope derivation, shared by the control plane's forensics authorization funnel and the standalone Query plane, with no DataFusion, Arrow, or engine dependency.
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
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
//! Verified principals and the scope a verified principal resolves to.
//!
//! # `Principal`: minted only by verification, never constructed
//!
//! [`Principal`] is a `pub` enum — a caller outside this crate can name it,
//! `match` it, and read its resolved fields through the accessor methods on
//! [`AdminPrincipal`]/[`ConversationGrantPrincipal`]/[`PersonaPrincipal`]/
//! [`ControlFleetPrincipal`]/[`AdminFleetPrincipal`] — but every one of those
//! inner types keeps its fields PRIVATE and exposes NO public constructor. The
//! only way to obtain a [`Principal`] value is to call
//! [`crate::credential::CredentialAuthority::verify_admin_session`] or
//! [`crate::credential::CredentialAuthority::verify_conversation_grant`].
//! Neither verification method takes a caller-supplied identity as a trusted
//! input: both re-derive the principal from a signed, verified artifact (a
//! session token or a grant token) plus, for the admin path, a FRESH
//! per-request read of the durable persona store.
//!
//! # `Scoping`: the same seal, for the derived scope
//!
//! [`Scoping`] carries what a verified [`Principal`] actually authorizes —
//! the [`crate::session::QueryScope`], the `EXPLAIN` policy, and the audit
//! attribution. Its fields are private and it has no public constructor, no
//! `Default`, and no public `From` in a production build. The only way to
//! obtain one is [`crate::credential::CredentialAuthority::scoping_for`] (or
//! [`crate::credential::CredentialAuthority::own_rows_scoping`]/
//! [`crate::credential::CredentialAuthority::composite_trace_memory_scope`],
//! its narrower siblings). `polyc-query` reads an already-built [`Scoping`]
//! through the accessor methods below; nothing outside this crate writes one.
//!
//! `polyc-query`'s deleted embedded engine once had two methods —
//! `QueryAuthority::fleet_scope`/`QueryAuthority::scope_for_turn` — that
//! built a [`Scoping`] directly rather than going through a verified
//! [`Principal`], for a TRUSTED caller with nothing to verify (a caller
//! already proven fleet-wide by the RPC router, or a turn's own dispatch
//! attribution). A census across `polyc-control-plane`, `polyc-query-service`,
//! and `polyc-query` at the time of the 10C-1 split (2026-09-19,
//! `origin/main` `303745c6f`) found no PRODUCTION caller of either, and both
//! went away with the embedded engine (POLY-196). The [`Scoping`]
//! constructors they needed stay gated behind
//! `#[cfg(any(test, feature = "test-util"))]` — `Scoping::for_test`,
//! `Scoping::fleet_admin_for_test`, and `Scoping::for_conversation_for_test`
//! — for this crate's and `polyc-query`'s own tests. Production composition
//! never enables `test-util`; if a future change wants a trusted-side scope
//! again, it cannot compile against the gated constructor, which is the
//! point — it forces a deliberate, reviewed change to this module instead of
//! silently widening the seal.
//!
//! # Pinning the seal
//!
//! `tests/compile-fail/sealed_construction.rs` (in this crate) proves, as an
//! externally-compiled fixture, that [`Scoping`] and every `*Principal` type
//! cannot be constructed from outside this crate — see that fixture's own
//! doc comment.

use std::sync::Arc;

use polyc_query_model::GrantSubject;

use crate::session::{MemorySources, QueryScope};

/// Current persona reads the credential mechanism needs, independent of where
/// the persona family is physically hosted.
#[async_trait::async_trait]
pub trait PersonaSource: Send + Sync {
    /// Resolves current liveness and privileged roles.
    async fn active_persona(
        &self,
        persona_id: String,
    ) -> Result<Option<polyc_persona::ActivePersona>, polyc_persona::PersonaError>;
    /// Reads current participation rows.
    async fn participations(
        &self,
        persona_id: String,
    ) -> Result<
        Vec<polyc_proto::proto::polychrome::persona::v1::Participation>,
        polyc_persona::PersonaError,
    >;
    /// Resolves bounded, visibility-filtered search scope.
    async fn participation_scope(
        &self,
        persona_id: String,
        cap: usize,
    ) -> Result<polyc_persona::ScopeResolution, polyc_persona::PersonaError>;
    /// Enumerates personas with usage rollups.
    async fn usage_rollup_index(&self) -> Result<Vec<String>, polyc_persona::PersonaError>;
    /// Reads one complete query reference snapshot.
    async fn reference_snapshot(
        &self,
        persona_id: String,
    ) -> Result<Option<polyc_persona::PersonaReferenceSnapshot>, polyc_persona::PersonaError>;
}

/// A handle onto the persona reads the credential mechanism needs.
///
/// One shape: a current [`PersonaSource`], or none.
#[derive(Clone)]
pub struct PersonaSourceHandle(Option<Arc<dyn PersonaSource>>);

impl PersonaSourceHandle {
    /// Binds a source this authority reads persona facts from.
    #[must_use]
    pub fn current(source: Arc<dyn PersonaSource>) -> Self {
        Self(Some(source))
    }

    /// Builds a handle no source is bound to.
    #[must_use]
    pub const fn unavailable() -> Self {
        Self(None)
    }

    /// Returns the bound source, if one is.
    ///
    /// [`crate::credential::CredentialAuthority`]'s own derivations read this
    /// directly — the ONE legitimate reason anything holds a raw
    /// [`PersonaSource`] handle rather than going through the authority's
    /// verifying methods: a derivation needs the persona store itself, not
    /// an authorization decision.
    #[must_use]
    pub fn load_full(&self) -> Option<Arc<dyn PersonaSource>> {
        self.0.clone()
    }
}

/// This crate's own name for a bound persona handle — kept as a distinct
/// alias so a future second persona-reading concern (there is none today)
/// would not have to disambiguate against this one.
pub(crate) type PersonaAccess = PersonaSourceHandle;

/// A verified admin principal — see
/// [`crate::credential::CredentialAuthority::verify_admin_session`].
///
/// No public constructor: the only way to obtain one is through that
/// verification method.
#[derive(Debug, Clone)]
pub struct AdminPrincipal {
    persona_id: String,
}

impl AdminPrincipal {
    /// Mints one verified admin principal.
    ///
    /// Crate-visible on purpose: [`crate::credential::CredentialAuthority`] is
    /// the only caller, so there is still no public way to mint one.
    pub(crate) const fn minted(persona_id: String) -> Self {
        Self { persona_id }
    }

    /// The verified admin's durable principal id.
    #[must_use]
    pub fn persona_id(&self) -> &str {
        &self.persona_id
    }
}

/// A verified conversation-scoped grant principal — see
/// [`crate::credential::CredentialAuthority::verify_conversation_grant`].
///
/// No public constructor: the only way to obtain one is through that
/// verification method.
#[derive(Debug, Clone)]
pub struct ConversationGrantPrincipal {
    conversation_id: String,
    subject: GrantSubject,
    memory_sources: Vec<String>,
}

impl ConversationGrantPrincipal {
    /// Mints one verified conversation-grant principal.
    ///
    /// `memory_sources` is the grant's PROPOSED co-participant list — verified
    /// only later, by
    /// [`crate::credential::CredentialAuthority::scoping_for`], against the
    /// participation authority. This constructor trusts nothing about it
    /// beyond "the signature over the whole token verified."
    pub(crate) const fn minted(
        conversation_id: String,
        subject: GrantSubject,
        memory_sources: Vec<String>,
    ) -> Self {
        Self {
            conversation_id,
            subject,
            memory_sources,
        }
    }

    /// The proposed `persona-memory/v1` co-participant sources — unverified.
    #[must_use]
    pub(crate) fn memory_sources(&self) -> &[String] {
        &self.memory_sources
    }

    /// The conversation this grant scopes queries to.
    #[must_use]
    pub fn conversation_id(&self) -> &str {
        &self.conversation_id
    }

    /// What this grant was minted for.
    #[must_use]
    pub const fn subject(&self) -> &GrantSubject {
        &self.subject
    }

    /// The turn this grant was minted for — the per-turn query budget's key.
    /// `None` when [`Self::subject`] is not [`GrantSubject::Turn`] (there is
    /// no turn to report, so this never fabricates one).
    #[must_use]
    #[allow(
        clippy::missing_const_for_fn,
        reason = "GrantSubject::turn_id isn't const either — see its own doc"
    )]
    pub fn turn_id(&self) -> Option<&str> {
        self.subject.turn_id()
    }
}

/// A verified persona-scoped principal.
///
/// Either a valid explorer session whose persona does NOT carry the durable
/// admin attribute, or a persona-wide [`GrantSubject::Persona`]
/// conversation-grant token (8C-2's `GetMyPayments` move) presented with the
/// empty conversation-id sentinel.
///
/// Minted by
/// [`crate::credential::CredentialAuthority::verify_admin_session`] for the
/// first case and by
/// [`crate::credential::CredentialAuthority::verify_conversation_grant`] for
/// the second; no public constructor.
///
/// [`crate::credential::CredentialAuthority::scoping_for`] resolves this
/// principal's own scope through [`PersonaSource::participations`], fresh per
/// request — never cached, never a full event replay.
#[derive(Debug, Clone)]
pub struct PersonaPrincipal {
    persona_id: String,
}

impl PersonaPrincipal {
    /// Mints one verified persona principal.
    pub(crate) const fn minted(persona_id: String) -> Self {
        Self { persona_id }
    }

    /// The persona this principal's queries are scoped to.
    #[must_use]
    pub fn persona_id(&self) -> &str {
        &self.persona_id
    }
}

/// A verified [`GrantSubject::ControlFleet`] capability — Control acting for
/// itself, after its own admission gate, for exactly one fixed statement.
///
/// Unlike every other principal, holding one is not by itself enough to run
/// anything: the standalone Query plane's own admission additionally checks
/// the realm and recomputes [`Self::statement_digest`] against the statement
/// actually being run before this principal's Fleet scope is honored. See
/// `docs/decisions/0016-control-fleet-query-capability.md`.
#[derive(Debug, Clone)]
pub struct ControlFleetPrincipal {
    purpose: String,
    statement_digest: [u8; 32],
}

impl ControlFleetPrincipal {
    /// Mints one verified Control-Fleet principal.
    #[cfg(not(any(test, feature = "test-util")))]
    pub(crate) const fn minted(purpose: String, statement_digest: [u8; 32]) -> Self {
        Self {
            purpose,
            statement_digest,
        }
    }

    /// [`Self::minted`], exposed cross-crate for `polyc-query`'s own
    /// `core_service` admission-fixture tests, which assert
    /// `admit_control_fleet` against a hand-built capability rather than a
    /// full grant-verification round trip.
    #[cfg(any(test, feature = "test-util"))]
    #[must_use]
    pub const fn minted(purpose: String, statement_digest: [u8; 32]) -> Self {
        Self {
            purpose,
            statement_digest,
        }
    }

    /// The stable name this grant authorizes — recorded as the query-audit
    /// requester's own name.
    #[must_use]
    pub fn purpose(&self) -> &str {
        &self.purpose
    }

    /// The SHA-256 this grant binds its authorization to. A verifier
    /// recomputes this over the statement it is about to run and refuses on
    /// any mismatch.
    #[must_use]
    pub const fn statement_digest(&self) -> &[u8; 32] {
        &self.statement_digest
    }
}

/// The fixed-statement capability carried by a verified composite-trace
/// conversation grant.
///
/// `memory` is `None` for a [`GrantSubject::AdminCompositeTrace`] grant
/// (POLY-394). That subject names no persona, so no memory statement can
/// match it: the capability does not carry a memory digest to match against.
///
/// `addresses` is `Some` only for a [`GrantSubject::AdminCompositeTrace`]
/// grant (POLY-464). A persona grant carries no address digest, so no
/// participant-address statement can match it.
#[derive(Debug, Clone)]
pub struct CompositeTracePrincipal {
    trace: String,
    memory: Option<String>,
    routine: String,
    addresses: Option<String>,
}

impl CompositeTracePrincipal {
    /// Mints the capability after credential verification has validated all
    /// three digest encodings.
    #[cfg(not(any(test, feature = "test-util")))]
    pub(crate) fn minted(trace: &str, memory: &str, routine: &str) -> Self {
        Self {
            trace: trace.to_owned(),
            memory: Some(memory.to_owned()),
            routine: routine.to_owned(),
            addresses: None,
        }
    }

    /// [`Self::minted`], exposed cross-crate for `polyc-query`'s own
    /// `core_service` composite-trace admission-fixture tests.
    #[cfg(any(test, feature = "test-util"))]
    #[must_use]
    pub fn minted(trace: &str, memory: &str, routine: &str) -> Self {
        Self {
            trace: trace.to_owned(),
            memory: Some(memory.to_owned()),
            routine: routine.to_owned(),
            addresses: None,
        }
    }

    /// Returns the trace statement digest.
    #[must_use]
    pub fn trace_statement_digest(&self) -> &str {
        &self.trace
    }

    /// Mints the capability of an admin composite-trace grant. It carries
    /// no memory digest. It carries the participant-address digest
    /// (POLY-464).
    #[cfg(not(any(test, feature = "test-util")))]
    pub(crate) fn minted_without_memory(trace: &str, routine: &str, addresses: &str) -> Self {
        Self {
            trace: trace.to_owned(),
            memory: None,
            routine: routine.to_owned(),
            addresses: Some(addresses.to_owned()),
        }
    }

    /// [`Self::minted_without_memory`], exposed cross-crate for
    /// `polyc-query`'s own `core_service` composite-trace admission-fixture
    /// tests.
    #[cfg(any(test, feature = "test-util"))]
    #[must_use]
    pub fn minted_without_memory(trace: &str, routine: &str, addresses: &str) -> Self {
        Self {
            trace: trace.to_owned(),
            memory: None,
            routine: routine.to_owned(),
            addresses: Some(addresses.to_owned()),
        }
    }

    /// Returns the memory statement digest, or `None` for an admin composite
    /// trace, which may not read memory.
    #[must_use]
    pub fn memory_statement_digest(&self) -> Option<&str> {
        self.memory.as_deref()
    }

    /// Returns the routine statement digest.
    #[must_use]
    pub fn routine_statement_digest(&self) -> &str {
        &self.routine
    }

    /// Returns the participant-address statement digest, or `None` for a
    /// persona composite trace, which may not read the addresses (POLY-464).
    #[must_use]
    pub fn address_statement_digest(&self) -> Option<&str> {
        self.addresses.as_deref()
    }
}

/// A verified [`GrantSubject::AdminFleet`] capability — a caller-held,
/// Fleet-scope, arbitrary-SQL credential minted for an authenticated admin
/// session (PR 10A, 2026-09-18).
///
/// Unlike [`ControlFleetPrincipal`], holding one is not gated on a statement
/// digest — there is none to check, since the admin has not typed the SQL yet
/// at mint time.
/// [`crate::credential::CredentialAuthority::scoping_for`] re-checks
/// `admin_persona` is CURRENTLY admin before honoring this principal's Fleet
/// scope for anything, and the standalone Query plane's own admission
/// additionally refuses it outside its Fleet realm. See
/// `docs/decisions/0022-explicit-query-credentials.md`.
#[derive(Debug, Clone)]
pub struct AdminFleetPrincipal {
    admin_persona: String,
    session: String,
}

impl AdminFleetPrincipal {
    /// Mints one verified admin-fleet principal.
    #[cfg(not(any(test, feature = "test-util")))]
    pub(crate) const fn minted(admin_persona: String, session: String) -> Self {
        Self {
            admin_persona,
            session,
        }
    }

    /// [`Self::minted`], exposed cross-crate for `polyc-query`'s own
    /// `core_service` admission-fixture tests.
    #[cfg(any(test, feature = "test-util"))]
    #[must_use]
    pub const fn minted(admin_persona: String, session: String) -> Self {
        Self {
            admin_persona,
            session,
        }
    }

    /// The admin persona this grant was minted for — re-verified as
    /// CURRENTLY admin before this principal's scope is honored, never
    /// trusted from mint time alone.
    #[must_use]
    pub fn admin_persona(&self) -> &str {
        &self.admin_persona
    }

    /// The session this grant rides — recorded in the query-audit requester
    /// alongside [`Self::admin_persona`].
    #[must_use]
    pub fn session(&self) -> &str {
        &self.session
    }
}

/// A verified caller identity, minted ONLY by
/// [`crate::credential::CredentialAuthority`]'s verification methods — see
/// this module's doc for why there is no public constructor.
#[derive(Debug, Clone)]
pub enum Principal {
    /// A maintainer/operator session, verified fleet-wide admin.
    Admin(AdminPrincipal),
    /// A conversation's own agent turn, verified via a signed grant token.
    ConversationGrant(ConversationGrantPrincipal),
    /// An end-user session, scoped to exactly its own participated
    /// conversations — see [`PersonaPrincipal`]'s doc.
    Persona(PersonaPrincipal),
    /// Control acting for itself, purpose- and statement-bound — see
    /// [`ControlFleetPrincipal`]'s own doc.
    ControlFleet(ControlFleetPrincipal),
    /// A caller-held Fleet-scope capability for arbitrary ad-hoc SQL — see
    /// [`AdminFleetPrincipal`]'s own doc.
    AdminFleet(AdminFleetPrincipal),
}

/// Failure verifying a [`Principal`] or scoping a query session to one.
#[derive(Debug, thiserror::Error)]
pub enum PrincipalError {
    /// No valid admin session: missing, malformed, expired, revoked, or
    /// signature-invalid token. Maps to 401.
    #[error("no valid admin session")]
    InvalidSession,
    /// The persona store could not answer whether the caller is an admin —
    /// the cell is empty, or the store itself errored reading the profile.
    /// Transient infrastructure state, maps to 503 — never 401/403.
    #[error("persona store unavailable")]
    StoreUnavailable,
    /// A real, verified session, but the persona id it names no longer
    /// resolves to any profile at all — there is no persona left to mint ANY
    /// principal for, admin or persona-scoped. Maps to 403. A verified
    /// session whose persona DOES resolve, but does not hold the fleet admin
    /// attribute does not land here — it mints a [`Principal::Persona`]
    /// instead.
    #[error("this account is not authorized for fleet-wide queries")]
    NotAuthorizedForFleet,
    /// The conversation grant token failed to verify: malformed, bad
    /// signature, or wrong `kind` tag — deliberately one undifferentiated
    /// outcome for every one of THOSE failure modes. Distinct from
    /// [`PrincipalError::GrantExpired`] — an otherwise-valid grant past its
    /// TTL is never folded in here.
    #[error("conversation grant token invalid")]
    InvalidGrant,
    /// The conversation grant token verified in every other respect (real
    /// signature, correct `kind` tag) but its TTL has elapsed. Deliberately
    /// its OWN variant, not folded into [`PrincipalError::InvalidGrant`]: an
    /// ordinary expiry is not a forgery, so a caller (the explorer web
    /// client) can tell the two apart and silently re-mint a fresh grant and
    /// retry once, instead of treating routine expiry like a probing/forged
    /// token. Maps to 401, same as [`PrincipalError::InvalidGrant`] — the two
    /// differ only in whether the caller should retry.
    #[error("conversation grant token expired")]
    GrantExpired,
    /// A verified [`Principal::ControlFleet`] was presented to an in-process
    /// verification path (`polyc-control-plane`'s forensics funnel — the
    /// "embedded" in this variant's name, from when that path lived beside
    /// the embedded engine), which has no per-request statement-digest gate
    /// to check it against — only the standalone Query plane's own admission
    /// may honor this principal. Refused unconditionally rather than granted
    /// the unrestricted Fleet access every other path to this scope would
    /// give it.
    #[error("this capability is standalone Query plane only")]
    ControlFleetNotUsableEmbedded,
    /// A verified [`Principal::AdminFleet`] was presented to an in-process
    /// verification path — the same refusal as
    /// [`PrincipalError::ControlFleetNotUsableEmbedded`], for the same
    /// reason: only the standalone Query plane's Fleet realm may honor this
    /// principal.
    #[error("this capability is standalone Query plane only")]
    AdminFleetNotUsableEmbedded,
    /// A conversation grant proposed a `persona-memory/v1` co-participant
    /// this mint cannot verify: the proposed id does not resolve through
    /// [`PersonaSource::active_persona`], or the resolved persona's
    /// participations do not include the grant's own conversation. Refuses
    /// the WHOLE mint — 6A-Q, `02-DESIGN.md` §2.3 — never scopes to the
    /// personas that DID verify while silently dropping the one that did
    /// not. Maps to 403: the caller asked for a partition the participation
    /// authority does not back.
    #[error("a proposed persona-memory source is outside this grant's scope")]
    SourceOutsideScope,
    /// `Scoping::for_conversation` found `subject` was a `ControlFleet` or
    /// `AdminFleet` shape wrapped inside a `Principal::ConversationGrant` —
    /// impossible by construction
    /// (`CredentialAuthority::verify_conversation_grant` mints those subjects
    /// into their OWN `Principal::ControlFleet`/`Principal::AdminFleet`
    /// variant, never a `ConversationGrant`). Reaching this refuses the one
    /// request that proved the invariant broke, logged at error level by the
    /// caller, rather than trusting a subject this scope was never built to
    /// carry — a broken upstream invariant must refuse one request, not
    /// panic the worker running it (PR 10A review round 3).
    #[error("this grant's subject cannot be scoped to a conversation")]
    MalformedConversationGrantSubject,
}

/// Which verified [`Principal`] variant a [`Scoping`] was derived from.
///
/// For a [`Principal::ConversationGrant`], also names which [`GrantSubject`]
/// — carried explicitly rather than re-derived from which of [`Scoping`]'s
/// optional fields happen to be set. The standalone Query plane's own edge
/// admission matches on this directly: inferring it from field presence
/// instead would silently start admitting a new [`GrantSubject`] shape at the
/// edge the day someone adds one, rather than failing to compile.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrincipalKind {
    /// [`Principal::Admin`], or the no-session forensics-bearer path that
    /// resolves to the identical Fleet/`allow_explain` pair the deleted
    /// embedded engine's `QueryAuthority::fleet_scope` used to produce.
    Admin,
    /// [`Principal::ConversationGrant`] whose subject is
    /// [`GrantSubject::Turn`].
    ConversationGrantTurn,
    /// [`Principal::ConversationGrant`] whose subject is
    /// [`GrantSubject::WebSession`].
    ConversationGrantWebSession,
    /// [`Principal::ConversationGrant`] whose subject is
    /// [`GrantSubject::Persona`] with a real conversation id — the
    /// routine-fire shape
    /// [`crate::credential::CredentialAuthority::verify_conversation_grant`]'s
    /// own doc distinguishes from the persona-wide, empty-conversation-id
    /// shape that mints [`Principal::Persona`] instead.
    ConversationGrantPersona,
    /// [`Principal::ConversationGrant`] whose subject is
    /// [`GrantSubject::CompositeTrace`].
    ConversationGrantCompositeTrace,
    /// [`Principal::ConversationGrant`] whose subject is
    /// [`GrantSubject::AdminCompositeTrace`]: an admin caller with no persona
    /// (POLY-394).
    ConversationGrantAdminCompositeTrace,
    /// [`Principal::Persona`] — a persona-wide grant or an ordinary
    /// non-admin explorer session.
    Persona,
    /// [`Principal::ControlFleet`].
    ControlFleet,
    /// [`Principal::AdminFleet`].
    AdminFleet,
}

/// What one verified [`Principal`] authorizes.
///
/// The whole per-caller half of a session, separated from the
/// deployment-wide collaborators `polyc-query` fills in when it builds a
/// runnable session from this value. Private fields, no public constructor,
/// no `Default`, and no public `From` in a production build — see this
/// module's doc for the seal this maintains and the `test-util`-gated
/// exceptions. `polyc-query` reads an already-built value through the
/// accessor methods below; nothing outside this crate writes one.
#[derive(Clone)]
pub struct Scoping {
    scope: QueryScope,
    allow_explain: bool,
    caller_identity: Option<String>,
    conversation_id: Option<String>,
    turn_id: Option<String>,
    web_session_id: Option<String>,
    control_fleet: Option<ControlFleetPrincipal>,
    admin_fleet: Option<AdminFleetPrincipal>,
    composite_trace: Option<CompositeTracePrincipal>,
    principal_kind: PrincipalKind,
}

/// [`Scoping`]'s fields, moved out rather than cloned.
///
/// `polyc-query`'s own private session constructor is the one caller that
/// needs ownership of every field at once; every other reader goes through
/// [`Scoping`]'s by-reference accessors instead.
pub struct ScopingParts {
    /// The partitions the session may register.
    pub scope: QueryScope,
    /// Whether `EXPLAIN` is available — Fleet only.
    pub allow_explain: bool,
    /// The verified persona id an audit record attributes the query to.
    pub caller_identity: Option<String>,
    /// The conversation an audit record names, for a single-conversation
    /// session.
    pub conversation_id: Option<String>,
    /// The turn an audit record names, and the per-turn budget's key.
    pub turn_id: Option<String>,
    /// The web session an audit record names — mutually exclusive with
    /// `turn_id`.
    pub web_session_id: Option<String>,
}

impl Scoping {
    /// The ONE conversation-scoped shape, derived from a conversation and the
    /// [`GrantSubject`] acting within it.
    ///
    /// Every conversation-scoped entry point comes through here — a verified
    /// [`Principal::ConversationGrant`] in
    /// [`crate::credential::CredentialAuthority::scoping_for`], and the
    /// test-only [`Self::for_conversation_for_test`] that took over the
    /// deleted embedded engine's `QueryAuthority::scope_for_turn` caller —
    /// so the two cannot drift. That
    /// matters most for `allow_explain`: a change that widened it (or the
    /// non-Fleet redacted registration it selects) on one path alone would
    /// be a redaction bypass reachable from exactly one surface, which is
    /// the hardest kind to notice in review.
    ///
    /// # Errors
    ///
    /// Returns [`PrincipalError::MalformedConversationGrantSubject`] if
    /// `subject` is a `ControlFleet`/`AdminFleet` shape — provably
    /// unreachable in production, refused rather than panicked so a broken
    /// upstream invariant fails the one request that exposed it instead of
    /// the worker running it.
    pub(crate) fn for_conversation(
        conversation_id: &str,
        subject: &GrantSubject,
    ) -> Result<Self, PrincipalError> {
        let composite_trace = subject
            .composite_trace()
            .map(|(_, trace, memory, routine)| {
                CompositeTracePrincipal::minted(trace, memory, routine)
            })
            .or_else(|| {
                subject
                    .admin_composite_trace()
                    .map(|(trace, routine, addresses)| {
                        CompositeTracePrincipal::minted_without_memory(trace, routine, addresses)
                    })
            });
        // Exhaustive on `GrantSubject` on purpose: `ControlFleet` and
        // `AdminFleet` are provably unreachable here —
        // `CredentialAuthority::verify_conversation_grant` special-cases
        // both into their own `Principal` variant before a
        // `Principal::ConversationGrant` (this function's only real-grant
        // caller) is ever minted, and the test-only
        // `for_conversation_for_test` (the other caller) only ever
        // constructs `GrantSubject::Turn` itself. A
        // refusal here would only fire if that upstream invariant broke, and
        // it should — silently mis-tagging the kind an edge admission check
        // matches on is worse than refusing loudly.
        let principal_kind = match subject {
            GrantSubject::Turn(_) => PrincipalKind::ConversationGrantTurn,
            GrantSubject::WebSession(_) => PrincipalKind::ConversationGrantWebSession,
            GrantSubject::Persona(_) => PrincipalKind::ConversationGrantPersona,
            GrantSubject::CompositeTrace { .. } => PrincipalKind::ConversationGrantCompositeTrace,
            GrantSubject::AdminCompositeTrace { .. } => {
                PrincipalKind::ConversationGrantAdminCompositeTrace
            }
            GrantSubject::ControlFleet { .. } | GrantSubject::AdminFleet { .. } => {
                tracing::error!(
                    "verify_conversation_grant's own invariant broke: a ControlFleet or \
                     AdminFleet subject reached Scoping::for_conversation wrapped in a \
                     Principal::ConversationGrant"
                );
                return Err(PrincipalError::MalformedConversationGrantSubject);
            }
        };
        Ok(Self {
            scope: QueryScope::Conversations {
                conversations: vec![conversation_id.to_owned()],
                memory: MemorySources::default(),
            },
            allow_explain: false,
            caller_identity: subject.persona_id().map(str::to_owned),
            conversation_id: Some(conversation_id.to_owned()),
            turn_id: subject.turn_id().map(str::to_owned),
            web_session_id: subject.web_session_id().map(str::to_owned),
            control_fleet: None,
            admin_fleet: None,
            composite_trace,
            principal_kind,
        })
    }

    pub(crate) fn set_scope(&mut self, scope: QueryScope) {
        self.scope = scope;
    }

    /// The [`Principal::Admin`] shape: fleet-wide, `EXPLAIN` available.
    pub(crate) fn admin(persona_id: &str) -> Self {
        Self {
            scope: QueryScope::Fleet,
            allow_explain: true,
            caller_identity: Some(persona_id.to_owned()),
            conversation_id: None,
            turn_id: None,
            web_session_id: None,
            control_fleet: None,
            admin_fleet: None,
            composite_trace: None,
            principal_kind: PrincipalKind::Admin,
        }
    }

    /// The [`Principal::Persona`] shape: exactly `conversation_ids`, no
    /// `EXPLAIN`, the owner-only `persona-memory/v1` partition.
    pub(crate) fn persona(persona_id: &str, conversation_ids: Vec<String>) -> Self {
        Self {
            scope: QueryScope::Conversations {
                conversations: conversation_ids,
                memory: MemorySources {
                    owner: Some(persona_id.to_owned()),
                    participants: Vec::new(),
                },
            },
            allow_explain: false,
            caller_identity: Some(persona_id.to_owned()),
            conversation_id: None,
            turn_id: None,
            web_session_id: None,
            control_fleet: None,
            admin_fleet: None,
            composite_trace: None,
            principal_kind: PrincipalKind::Persona,
        }
    }

    /// The [`Principal::ControlFleet`] shape: fleet-wide, no `EXPLAIN`, no
    /// caller identity — the requester the query audit names comes from
    /// `control_fleet` itself, not this field.
    pub(crate) const fn from_control_fleet(control_fleet: ControlFleetPrincipal) -> Self {
        Self {
            scope: QueryScope::Fleet,
            allow_explain: false,
            caller_identity: None,
            conversation_id: None,
            turn_id: None,
            web_session_id: None,
            control_fleet: Some(control_fleet),
            admin_fleet: None,
            composite_trace: None,
            principal_kind: PrincipalKind::ControlFleet,
        }
    }

    /// The [`Principal::AdminFleet`] shape: fleet-wide, `EXPLAIN` available
    /// (matching [`Principal::Admin`]), `caller_identity` the FRESH persona
    /// id re-resolved at verification time.
    pub(crate) const fn from_admin_fleet(
        caller_persona_id: String,
        admin_fleet: AdminFleetPrincipal,
    ) -> Self {
        Self {
            scope: QueryScope::Fleet,
            allow_explain: true,
            caller_identity: Some(caller_persona_id),
            conversation_id: None,
            turn_id: None,
            web_session_id: None,
            control_fleet: None,
            admin_fleet: Some(admin_fleet),
            composite_trace: None,
            principal_kind: PrincipalKind::AdminFleet,
        }
    }

    /// The "my own rows" shape [`crate::credential::CredentialAuthority::own_rows_scoping`]
    /// mints — ignores admin status entirely, by construction.
    pub(crate) fn own_rows(persona_id: &str, conversation_ids: Vec<String>) -> Self {
        Self {
            scope: QueryScope::Conversations {
                conversations: conversation_ids,
                memory: MemorySources::default(),
            },
            allow_explain: false,
            caller_identity: Some(persona_id.to_owned()),
            conversation_id: None,
            turn_id: None,
            web_session_id: None,
            control_fleet: None,
            admin_fleet: None,
            composite_trace: None,
            // This constructor's own caller is reached only via a bearer
            // already verified another way (Control's explorer session
            // authority), never through `CredentialWitness`'s grant/bearer
            // admission — so no edge admission check ever inspects this
            // value either. Tagged `Persona` as the closest semantic match.
            principal_kind: PrincipalKind::Persona,
        }
    }

    /// The partitions this session may register.
    #[must_use]
    pub const fn scope(&self) -> &QueryScope {
        &self.scope
    }

    /// Whether `EXPLAIN` is available — Fleet only.
    #[must_use]
    pub const fn allow_explain(&self) -> bool {
        self.allow_explain
    }

    /// The verified persona id an audit record attributes the query to,
    /// including a persona-subject conversation grant.
    #[must_use]
    pub fn caller_identity(&self) -> Option<&str> {
        self.caller_identity.as_deref()
    }

    /// The conversation an audit record names, for a single-conversation
    /// session.
    #[must_use]
    pub fn conversation_id(&self) -> Option<&str> {
        self.conversation_id.as_deref()
    }

    /// The turn an audit record names, and the per-turn budget's key.
    #[must_use]
    pub fn turn_id(&self) -> Option<&str> {
        self.turn_id.as_deref()
    }

    /// The web session an audit record names — mutually exclusive with
    /// [`Self::turn_id`].
    #[must_use]
    pub fn web_session_id(&self) -> Option<&str> {
        self.web_session_id.as_deref()
    }

    /// Present only for a verified [`Principal::ControlFleet`] — the purpose
    /// and statement digest the standalone Query plane's own admission
    /// checks before honoring this scope for anything. `None` for every
    /// other principal, including every other Fleet-scoped one
    /// ([`Principal::Admin`]): the digest gate
    /// applies to this one purpose-bound capability, never to an admin
    /// session's own unrestricted Fleet access.
    #[must_use]
    pub const fn control_fleet(&self) -> Option<&ControlFleetPrincipal> {
        self.control_fleet.as_ref()
    }

    /// Present only for a verified [`Principal::AdminFleet`] — the admin
    /// persona and session the standalone Query plane's own admission checks
    /// the realm against before honoring this scope for anything. `None` for
    /// every other principal, including [`Principal::Admin`] itself.
    #[must_use]
    pub const fn admin_fleet(&self) -> Option<&AdminFleetPrincipal> {
        self.admin_fleet.as_ref()
    }

    /// Present only for a verified composite-trace conversation grant.
    #[must_use]
    pub const fn composite_trace(&self) -> Option<&CompositeTracePrincipal> {
        self.composite_trace.as_ref()
    }

    /// Which verified [`Principal`] variant this `Scoping` was derived from —
    /// see [`PrincipalKind`]'s own doc.
    #[must_use]
    pub const fn principal_kind(&self) -> PrincipalKind {
        self.principal_kind
    }

    /// Moves every field out, for `polyc-query`'s own private session
    /// constructor — the one caller that needs ownership of all of them at
    /// once. See [`ScopingParts`]'s own doc.
    #[must_use]
    pub fn into_parts(self) -> ScopingParts {
        ScopingParts {
            scope: self.scope,
            allow_explain: self.allow_explain,
            caller_identity: self.caller_identity,
            conversation_id: self.conversation_id,
            turn_id: self.turn_id,
            web_session_id: self.web_session_id,
        }
    }

    /// Every field, for a test fixture that has no verified [`Principal`] to
    /// derive a [`Scoping`] from — `polyc-query`'s own
    /// `core_service`/`authority` test modules, which assert admission
    /// behaviour against a hand-built scope shape rather than a full
    /// verification round trip.
    ///
    /// Gated to `test`/`test-util` on both sides of the crate boundary — see
    /// this module's own doc for why this does not widen the production
    /// seal.
    #[cfg(any(test, feature = "test-util"))]
    #[must_use]
    #[allow(
        clippy::too_many_arguments,
        reason = "one field per Scoping field, no grouping reduces this"
    )]
    pub const fn for_test(
        scope: QueryScope,
        allow_explain: bool,
        caller_identity: Option<String>,
        conversation_id: Option<String>,
        turn_id: Option<String>,
        web_session_id: Option<String>,
        control_fleet: Option<ControlFleetPrincipal>,
        admin_fleet: Option<AdminFleetPrincipal>,
        composite_trace: Option<CompositeTracePrincipal>,
        principal_kind: PrincipalKind,
    ) -> Self {
        Self {
            scope,
            allow_explain,
            caller_identity,
            conversation_id,
            turn_id,
            web_session_id,
            control_fleet,
            admin_fleet,
            composite_trace,
            principal_kind,
        }
    }

    /// Overwrites [`Self::scope`] after construction — test-only, for a
    /// fixture that starts from a real shape and mutates one field to prove a
    /// check does not trust the mapping that normally produces it.
    #[cfg(any(test, feature = "test-util"))]
    pub fn set_scope_for_test(&mut self, scope: QueryScope) {
        self.scope = scope;
    }

    /// The literal Fleet/`allow_explain: true`/[`PrincipalKind::Admin`] shape
    /// `polyc-query`'s deleted embedded engine resolved through
    /// `QueryAuthority::fleet_scope` — see this module's own doc for why this
    /// constructor is gated to `test`/`test-util`.
    #[cfg(any(test, feature = "test-util"))]
    #[must_use]
    pub const fn fleet_admin_for_test() -> Self {
        Self {
            scope: QueryScope::Fleet,
            allow_explain: true,
            caller_identity: None,
            conversation_id: None,
            turn_id: None,
            web_session_id: None,
            control_fleet: None,
            admin_fleet: None,
            composite_trace: None,
            principal_kind: PrincipalKind::Admin,
        }
    }

    /// [`Self::for_conversation`], for a trusted-side conversation scope a
    /// test builds directly — the role `polyc-query`'s deleted embedded
    /// engine filled through `QueryAuthority::scope_for_turn`. See this
    /// module's own doc for why this constructor is gated to
    /// `test`/`test-util`.
    ///
    /// # Errors
    ///
    /// See [`Self::for_conversation`].
    #[cfg(any(test, feature = "test-util"))]
    pub fn for_conversation_for_test(
        conversation_id: &str,
        subject: &GrantSubject,
    ) -> Result<Self, PrincipalError> {
        Self::for_conversation(conversation_id, subject)
    }
}

// ---------------------------------------------------------------------
// Trusted participation search-scope authority (W1 of the participation-
// scoped search design) — a narrow entry point for a TRUSTED caller (the
// control plane, holding a turn's own dispatch attribution) to resolve the
// participation-scoped search surface's authorized conversation set. See
// `crate::credential::CredentialAuthority::resolve_search_scope`.
// ---------------------------------------------------------------------

/// Ceiling on a persona's total participation count
/// `CredentialAuthority::resolve_search_scope` will resolve before refusing
/// outright — see [`SearchScopeError::OverCap`].
///
/// Matches `PersonaSource::participation_scope`'s own `cap` parameter, which
/// checks this BEFORE any per-conversation tombstone read runs: resolving the
/// scope is itself unbounded work on the approval path, and the check has to
/// bound it before that per-tie cost is paid, not after.
///
/// The design record deliberately does not pin an exact number here — W0
/// owns the operational SLOs — beyond stating that a real cap must sit well
/// below the persona-record capacity ceiling (~27,600 conversations, from the
/// participation index's own 1 MiB record cap; see
/// `PersonaSource::participation_scope`'s doc). 5,000 is a conservative,
/// documented placeholder that leaves that headroom; revisit once W0 lands an
/// operational number.
pub const SEARCH_SCOPE_CAP: usize = 5_000;

/// Domain separator for `CredentialAuthority::resolve_search_scope`'s scope
/// hash — see [`SearchScope::hash`]'s own doc for the full five-step
/// algorithm this pins. The version lives in this string (`v1`): changing any
/// step of the algorithm means minting a new domain string, which
/// deliberately invalidates every outstanding approval bound to the old hash
/// rather than silently reinterpreting it under a changed formula.
const SEARCH_SCOPE_HASH_DOMAIN: &[u8] = b"polychrome.search.scope.v1";

/// The result of resolving a persona's trusted participation search scope —
/// see `CredentialAuthority::resolve_search_scope`.
///
/// No public constructor: the only way to obtain one is through that method.
/// Carries the canonical (sorted, deduplicated) conversation set the
/// participation-scoped search surface may read, its count, and a stable hash
/// over it — never the [`Principal`] or raw [`QueryScope`] a caller could use
/// to mint its own session, and never the calling conversation, which
/// `resolve_search_scope` always removes before this value is constructed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchScope {
    conversation_ids: Vec<String>,
    hash: String,
}

impl SearchScope {
    pub(crate) const fn minted(conversation_ids: Vec<String>, hash: String) -> Self {
        Self {
            conversation_ids,
            hash,
        }
    }

    /// The canonical (sorted, deduplicated) conversation set.
    #[must_use]
    pub fn conversation_ids(&self) -> &[String] {
        &self.conversation_ids
    }

    /// The number of conversations in this scope.
    #[must_use]
    pub const fn count(&self) -> usize {
        self.conversation_ids.len()
    }

    /// The stable hash over the canonical conversation set.
    #[must_use]
    pub fn hash(&self) -> &str {
        &self.hash
    }
}

/// Failure resolving a [`SearchScope`].
#[derive(Debug, thiserror::Error)]
pub enum SearchScopeError {
    /// The persona store could not answer whether `principal_ref` is active
    /// — the cell is empty, or the store itself errored reading the profile.
    /// Transient infrastructure state, never folded into
    /// [`SearchScopeError::PersonaNotActive`].
    #[error("persona store unavailable")]
    StoreUnavailable,
    /// `principal_ref` does not resolve to a currently-active persona.
    #[error("persona is not active")]
    PersonaNotActive,
    /// The persona's participation count exceeds [`SEARCH_SCOPE_CAP`].
    #[error("participation count exceeds the search cap")]
    OverCap {
        /// The persona's actual participation count.
        count: usize,
    },
}

/// Canonicalize `conversation_ids` (encode, sort bytewise, dedupe) and hash
/// them per [`SearchScope::hash`]'s own pinned five-step algorithm.
///
/// Returns the canonical (sorted, deduplicated) conversation ids alongside
/// the lower-hex BLAKE3 digest computed over them. Sorting the length-
/// prefixed encoded records (not the raw strings) is what makes the result
/// independent of the caller's own enumeration order — the property the
/// approval binding this scope feeds into rests on.
///
/// # Panics
///
/// Never panics in practice: a conversation id whose UTF-8 byte length
/// exceeds `u32::MAX` is not an id any part of this system mints (chat-edge
/// conversation ids are `UUIDv5` strings, a few dozen bytes).
pub(crate) fn canonical_search_scope(conversation_ids: Vec<String>) -> (Vec<String>, String) {
    let mut encoded: Vec<(Vec<u8>, String)> = conversation_ids
        .into_iter()
        .map(|id| {
            let bytes = id.as_bytes();
            let len = u32::try_from(bytes.len())
                .expect("conversation id byte length exceeds u32 — not an id this system mints");
            let mut record = Vec::with_capacity(4 + bytes.len());
            record.extend_from_slice(&len.to_be_bytes());
            record.extend_from_slice(bytes);
            (record, id)
        })
        .collect();
    encoded.sort_by(|(a, _), (b, _)| a.cmp(b));
    encoded.dedup_by(|(a, _), (b, _)| a == b);

    let total_len = SEARCH_SCOPE_HASH_DOMAIN.len()
        + 1
        + encoded
            .iter()
            .map(|(record, _)| record.len())
            .sum::<usize>();
    let mut buf = Vec::with_capacity(total_len);
    buf.extend_from_slice(SEARCH_SCOPE_HASH_DOMAIN);
    buf.push(0);
    for (record, _) in &encoded {
        buf.extend_from_slice(record);
    }
    let hash = blake3::hash(&buf).to_hex().to_string();

    let canonical_ids = encoded.into_iter().map(|(_, id)| id).collect();
    (canonical_ids, hash)
}