polyc-query-model 2026.9.6

DataFusion-free semantic request and result vocabulary shared by Query clients and the Query service.
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
//! Conversation-grant token codec (POLY-320).
//!
//! A conversation grant is a short-lived, self-contained bearer token: JSON
//! claims, ed25519-signed as a whole, wire-encoded as
//! `base64url(claims_json).base64url(signature)`. Verification is purely
//! cryptographic — decode, check the signature against a trusted key, check
//! the `kind` tag, check expiry — with no database lookup for a
//! `WebSession` subject, so any caller holding a turn-read signing key can
//! mint one and any caller holding the matching public trust set can verify
//! one, with no shared mutable registry between mint and verify.
//!
//! This module lives here, in the `DataFusion`-free query vocabulary crate,
//! rather than in `polyc-query` (which owns the token's verification and
//! carries `polyc-query`'s own re-export of every item here for source
//! compatibility), so a caller that needs to mint a grant does not have to
//! link `DataFusion`, Arrow, or Query's execution engine to do it. Control's
//! browser, turn, and routine-owner routes mint the three per-conversation
//! subject shapes holding Control's own signing key; `polychrome query
//! verify` mints a `WebSession` grant from a local turn-read signing key when
//! dialing the standalone Query plane directly outside Control.
//!
//! # `ControlFleet`: a purpose-bound Fleet capability, not a conversation grant
//!
//! Every subject above scopes to exactly one conversation —
//! [`GrantClaims`]'s `conversation_id` is a field every one of them carries a
//! real value in. [`GrantSubject::ControlFleet`] is the one exception: it
//! names no conversation at all (mint with [`mint_control_fleet_grant`],
//! which fixes `conversation_id` to an empty string a verifier must never
//! read for this subject) and instead binds two things a Fleet-scope reader
//! checks before it trusts the grant for anything: `purpose`, a stable name
//! for which fixed read this grant authorizes (e.g.
//! `"dashboard.conversations"`), and `statement_digest`, the lowercase-hex
//! SHA-256 of the exact SQL text that read may run. A grant good for one
//! statement is not good for another, even under the same purpose — the
//! digest is what a verifier actually checks; `purpose` is only what the
//! query-audit trail names it as. See
//! `docs/decisions/0016-control-fleet-query-capability.md` for the full
//! design and `crates/query/src/core_service.rs`'s `admit_control_fleet` for
//! where the digest, scope, and realm are actually checked — this module
//! only carries the claim, it verifies nothing.

use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use polyc_crypto::signing_role::{SigningRole as _, TurnReadRole, TurnReadSigner};
use serde::{Deserialize, Serialize};

/// Domain-separator tag pinned as the FIRST field of [`GrantClaims`].
///
/// Being first means it is also the first bytes of the signed canonical,
/// since `serde_json` serializes a struct's fields in declaration order —
/// mirrors `polyc_crypto`'s canonical-JSON "literal `kind` tag first"
/// convention. The dedicated turn-read signing role prevents cross-purpose
/// use at the type/custody boundary; this tag additionally binds the exact
/// artifact shape.
pub const GRANT_KIND: &str = "query_conversation_grant.v2";

/// What a conversation-grant token was minted for.
///
/// Every grant scopes queries to exactly one conversation — this enum names
/// WHO the grant additionally speaks for within that conversation, so a
/// durable read-audit record never has to fabricate a turn id for a caller
/// that has none. `GrantSubject::Turn` is a turn dispatch: Control mints it
/// only when that turn crosses into the standalone Query plane. The
/// remaining embedded facts still resolve their scope in process.
/// `GrantSubject::WebSession` is an authenticated explorer session viewing
/// this conversation (#1576's own mint endpoint) — no turn backs it, so it
/// carries a session-scoped identifier and its own, separately budgeted
/// rolling window instead. `GrantSubject::Persona` is a verified persona
/// acting outside a turn or browser session, such as the owner reading one
/// of their routine fires — that use always names a real conversation id.
///
/// `GrantSubject::Persona` has a second, narrower shape: minted with the
/// EMPTY conversation-id sentinel (never a hand-rolled empty string — use
/// [`mint_persona_wide_grant`]), it names no single conversation at all and
/// instead scopes to every conversation the named persona participates in —
/// [`crate::grant`]'s own "one persona's own subject across every
/// conversation" gap this closes (8C-2's `GetMyPayments` move). A verifier
/// tells the two shapes apart the same way it already tells
/// [`GrantSubject::ControlFleet`] apart from every conversation-bound
/// subject: by whether the claimed `conversation_id` is empty, never by a
/// separate tag. This is safe only because minting is itself trust-gated (a
/// `TurnReadSigner` holder) — the routine-fire shape above always supplies a
/// real conversation id, so no legitimate mint site can collide with this
/// one by accident.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "id", rename_all = "snake_case")]
pub enum GrantSubject {
    /// A harness turn dispatch — the turn id the per-turn query budget keys
    /// on.
    Turn(String),
    /// An authenticated explorer session with no turn of its own — a
    /// session-scoped identifier (today, the session's own verified persona
    /// id), never a fabricated turn id.
    WebSession(String),
    /// A verified persona acting without a turn or browser session.
    Persona(String),
    /// A participant viewing one composite trace through three fixed
    /// statements. The persona id is the verified caller and the three
    /// digests bind this grant to the route's complete SQL vocabulary.
    CompositeTrace {
        /// The verified caller persona and owner memory partition.
        persona_id: String,
        /// Lowercase-hex SHA-256 of the trace statement.
        trace_statement_digest: String,
        /// Lowercase-hex SHA-256 of the memory statement.
        memory_statement_digest: String,
        /// Lowercase-hex SHA-256 of the observed-routine statement.
        routine_statement_digest: String,
    },
    /// An admin caller with no persona viewing one composite trace (POLY-394).
    ///
    /// Control mints this subject only after its own admission gate admitted
    /// an explorer-read or forensics service bearer. That bearer names no
    /// persona (INV-ER4). The memory half of a composite trace reads the
    /// caller's own `persona-{id}-mem` partition, so it has no meaning for
    /// this caller. This subject therefore carries no persona and no memory
    /// digest. A verifier cannot form an owner partition from it, and no
    /// memory statement can match it.
    ///
    /// A sibling of [`Self::CompositeTrace`], not an optional field on it:
    /// the persona-carrying subject keeps its exact signed bytes.
    AdminCompositeTrace {
        /// Lowercase-hex SHA-256 of the trace statement.
        trace_statement_digest: String,
        /// Lowercase-hex SHA-256 of the observed-routine statement.
        routine_statement_digest: String,
    },
    /// A purpose-bound Fleet-scope capability Control mints for itself, after
    /// its own admission gate (an admin session or a forensics key) has
    /// already admitted the caller. Names no conversation — see this
    /// module's own doc for the full contract and why this is the one
    /// subject `mint_conversation_grant` must never be used to mint.
    ControlFleet {
        /// A stable name for which fixed read this grant authorizes (e.g.
        /// `"dashboard.conversations"`). Recorded as the query-audit
        /// requester's own name; carries no authority on its own.
        purpose: String,
        /// Lowercase-hex SHA-256 of the exact SQL text this grant authorizes.
        /// A verifier recomputes this digest over the statement it is about
        /// to run and refuses on any mismatch — this is the field that
        /// actually binds the grant to one statement.
        statement_digest: String,
    },
    /// A caller-held Fleet-scope capability for arbitrary, ad-hoc SQL —
    /// minted by Control for an authenticated admin session (2026-09-18,
    /// PR 10A). A sibling of [`Self::ControlFleet`] with two differences:
    /// the CALLER holds this one (not Control acting for itself), and it
    /// binds no statement digest — the whole point is that the admin has not
    /// typed the SQL yet at mint time, so nothing could be hashed even if
    /// this shape wanted to.
    ///
    /// A verifier re-checks `admin_persona` is CURRENTLY admin at query time
    /// (never trusted from mint time alone — a demoted admin is refused
    /// inside this grant's own TTL) and refuses this subject anywhere but the
    /// standalone Query plane's Fleet realm — see
    /// `docs/decisions/0022-explicit-query-credentials.md` for the full
    /// design and threat model.
    AdminFleet {
        /// The admin persona this grant was minted for.
        admin_persona: String,
        /// The session this grant rides — carried into the query-audit
        /// requester alongside `admin_persona`, so the audit trail names
        /// both.
        session: String,
    },
}

impl GrantSubject {
    /// The turn id, when this subject is [`GrantSubject::Turn`] — `None` for
    /// every non-turn subject, never a fabricated value.
    ///
    /// Not `const`: `Some(id)` here relies on `String`'s non-const `Deref`
    /// coercion to `&str`, which `rustc` rejects in a `const fn` (E0015) —
    /// clippy's `missing_const_for_fn` suggestion is a false positive for
    /// this exact shape.
    #[allow(
        clippy::missing_const_for_fn,
        reason = "String's Deref to str isn't const (E0015)"
    )]
    #[must_use]
    pub fn turn_id(&self) -> Option<&str> {
        match self {
            Self::Turn(id) => Some(id),
            Self::WebSession(_)
            | Self::Persona(_)
            | Self::CompositeTrace { .. }
            | Self::AdminCompositeTrace { .. }
            | Self::ControlFleet { .. }
            | Self::AdminFleet { .. } => None,
        }
    }

    /// The web-session id, when this subject is [`GrantSubject::WebSession`]
    /// — `None` for every other subject.
    #[allow(clippy::missing_const_for_fn, reason = "see turn_id's own doc above")]
    #[must_use]
    pub fn web_session_id(&self) -> Option<&str> {
        match self {
            Self::WebSession(id) => Some(id),
            Self::Turn(_)
            | Self::Persona(_)
            | Self::CompositeTrace { .. }
            | Self::AdminCompositeTrace { .. }
            | Self::ControlFleet { .. }
            | Self::AdminFleet { .. } => None,
        }
    }

    /// The verified persona id, when this subject is
    /// [`GrantSubject::Persona`].
    #[allow(clippy::missing_const_for_fn, reason = "see turn_id's own doc above")]
    #[must_use]
    pub fn persona_id(&self) -> Option<&str> {
        match self {
            Self::Persona(id) => Some(id),
            Self::CompositeTrace { persona_id, .. } => Some(persona_id),
            Self::Turn(_)
            | Self::WebSession(_)
            | Self::AdminCompositeTrace { .. }
            | Self::ControlFleet { .. }
            | Self::AdminFleet { .. } => None,
        }
    }

    /// The purpose and statement digest, when this subject is
    /// [`GrantSubject::ControlFleet`] — `None` for every other subject.
    #[must_use]
    pub fn control_fleet(&self) -> Option<(&str, &str)> {
        match self {
            Self::ControlFleet {
                purpose,
                statement_digest,
            } => Some((purpose, statement_digest)),
            Self::Turn(_)
            | Self::WebSession(_)
            | Self::Persona(_)
            | Self::CompositeTrace { .. }
            | Self::AdminCompositeTrace { .. }
            | Self::AdminFleet { .. } => None,
        }
    }

    /// The admin persona and session, when this subject is
    /// [`GrantSubject::AdminFleet`] — `None` for every other subject.
    #[must_use]
    pub fn admin_fleet(&self) -> Option<(&str, &str)> {
        match self {
            Self::AdminFleet {
                admin_persona,
                session,
            } => Some((admin_persona, session)),
            Self::Turn(_)
            | Self::WebSession(_)
            | Self::Persona(_)
            | Self::CompositeTrace { .. }
            | Self::AdminCompositeTrace { .. }
            | Self::ControlFleet { .. } => None,
        }
    }

    /// Returns the caller and the three fixed statement digests for a
    /// composite-trace subject.
    #[must_use]
    pub fn composite_trace(&self) -> Option<(&str, &str, &str, &str)> {
        match self {
            Self::CompositeTrace {
                persona_id,
                trace_statement_digest,
                memory_statement_digest,
                routine_statement_digest,
            } => Some((
                persona_id,
                trace_statement_digest,
                memory_statement_digest,
                routine_statement_digest,
            )),
            Self::Turn(_)
            | Self::WebSession(_)
            | Self::Persona(_)
            | Self::AdminCompositeTrace { .. }
            | Self::ControlFleet { .. }
            | Self::AdminFleet { .. } => None,
        }
    }

    /// Returns the two fixed statement digests for an admin composite-trace
    /// subject. It carries no persona and no memory digest.
    #[must_use]
    pub fn admin_composite_trace(&self) -> Option<(&str, &str)> {
        match self {
            Self::AdminCompositeTrace {
                trace_statement_digest,
                routine_statement_digest,
            } => Some((trace_statement_digest, routine_statement_digest)),
            Self::Turn(_)
            | Self::WebSession(_)
            | Self::Persona(_)
            | Self::CompositeTrace { .. }
            | Self::ControlFleet { .. }
            | Self::AdminFleet { .. } => None,
        }
    }

    /// The verified persona this grant speaks for as an OWNER, when this
    /// subject carries one.
    ///
    /// `Some` for [`Self::Persona`] and [`Self::WebSession`] — a `WebSession`
    /// id is, by contract, "the session's own verified persona id"
    /// (6A-Q, `02-DESIGN.md` §2.4) — `None` for [`Self::Turn`], which speaks
    /// for a turn dispatch rather than any one persona's own memory
    /// partition. This is the contract a `persona-memory/v1` owner-audience
    /// admission binds to: every mint site that produces a `WebSession` or
    /// `Persona` subject is asserting that its id IS the caller's own
    /// verified persona, never a value trusted from elsewhere.
    #[allow(clippy::missing_const_for_fn, reason = "see turn_id's own doc above")]
    #[must_use]
    pub fn owner_persona_id(&self) -> Option<&str> {
        match self {
            Self::Persona(id) | Self::WebSession(id) => Some(id),
            Self::CompositeTrace { persona_id, .. } => Some(persona_id),
            // A turn dispatch speaks for the turn, not for any one persona's
            // memory partition, and `ControlFleet`/`AdminFleet` speak for
            // Control or a fleet-wide capability, never for one persona's own
            // memory partition — neither names a conversation at all (this
            // module's own doc on each variant). An admin composite trace
            // names no persona, so it has no owner partition (POLY-394).
            Self::Turn(_)
            | Self::AdminCompositeTrace { .. }
            | Self::ControlFleet { .. }
            | Self::AdminFleet { .. } => None,
        }
    }
}

/// The signed claims a grant token carries.
///
/// JSON-encoded then ed25519-signed as a whole — never trust a conversation
/// id or grant subject from anywhere else on the conversation-scoped
/// surface. `kind` is always [`GRANT_KIND`].
///
/// Fields are private: a verifier reads them only through the accessors
/// below, plus the consuming [`GrantClaims::into_conversation_and_subject`]
/// for the two fields a verified caller is meant to keep.
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GrantClaims {
    kind: String,
    issuer: String,
    key_id: String,
    conversation_id: String,
    subject: GrantSubject,
    expires_at_ms: u64,
    /// Persona-memory partitions this grant proposes as READABLE alongside
    /// the grant's own subject's owner partition — never trusted as-is.
    ///
    /// Empty for every grant minted before 6A-Q and for every grant that
    /// does not target `persona-memory/v1` at all: `#[serde(default)]` reads
    /// an absent field as empty rather than refusing the token, since this
    /// field never widens what an old-format token could already do. A
    /// verifier resolves each proposed id through the participation
    /// authority before it becomes part of a minted scope (`polyc-query`'s
    /// `CredentialAuthority::scoping_for`); `SourceOutsideScope` refuses the
    /// whole mint if one fails.
    #[serde(default)]
    memory_sources: Vec<String>,
}

impl GrantClaims {
    /// The claimed token kind — compare against [`GRANT_KIND`], never trust
    /// without checking.
    #[must_use]
    pub fn kind(&self) -> &str {
        &self.kind
    }

    /// The claimed signer issuer — a verifier checks this against its own
    /// role's issuer constant before trusting the signature.
    #[must_use]
    pub fn issuer(&self) -> &str {
        &self.issuer
    }

    /// The claimed signing key id — a verifier looks this key up in its own
    /// trusted set; an unrecognized id must fail closed.
    #[must_use]
    pub fn key_id(&self) -> &str {
        &self.key_id
    }

    /// The millisecond Unix timestamp after which this grant is no longer
    /// valid.
    #[must_use]
    pub const fn expires_at_ms(&self) -> u64 {
        self.expires_at_ms
    }

    /// Consume the verified claims, keeping only the conversation id and
    /// subject a verifier needs to build its principal — the `kind`,
    /// `issuer`, `key_id`, and `expires_at_ms` fields have already served
    /// their purpose once the caller reaches this point.
    #[must_use]
    pub fn into_conversation_and_subject(self) -> (String, GrantSubject) {
        (self.conversation_id, self.subject)
    }

    /// Consume the verified claims, keeping the conversation id, subject,
    /// and proposed persona-memory sources — the memory-aware sibling of
    /// [`Self::into_conversation_and_subject`].
    #[must_use]
    pub fn into_parts(self) -> (String, GrantSubject, Vec<String>) {
        (self.conversation_id, self.subject, self.memory_sources)
    }
}

/// Mint a grant token binding `conversation_id` + `subject`, signed by
/// `signer` and valid until `expires_at_ms`.
///
/// The wire form is
/// `base64url(claims_json).base64url(signature)` — self-contained (no shared
/// mutable registry between the minting call site and Query's own
/// verification).
///
/// Any caller holding a `TurnReadSigner` can mint: Control's browser, turn,
/// and routine-owner routes mint the three subject shapes with Control's
/// own signing key; `polychrome query verify` mints a `WebSession` grant
/// from a local turn-read signing key that native mode's own material
/// custody places on disk, to dial the standalone Query plane directly
/// without going through Control at all.
///
/// # Panics
///
/// Never panics in practice: `GrantClaims` is a plain struct with no
/// fallible field types, so its own `serde_json` encoding step cannot fail.
#[must_use]
pub fn mint_conversation_grant(
    signer: &TurnReadSigner,
    conversation_id: &str,
    subject: GrantSubject,
    expires_at_ms: u64,
) -> String {
    mint_conversation_grant_for_memory(signer, conversation_id, subject, expires_at_ms, Vec::new())
}

/// [`mint_conversation_grant`]'s sibling, additionally proposing `memory_sources`.
///
/// `memory_sources` names persona ids the grant asks to admit
/// `persona-memory/v1` reads for, alongside the subject's own owner
/// partition. Verified, never trusted, by the same rule every other claim in
/// this token gets: a verifier resolves each id through the participation
/// authority before it becomes part of a minted scope.
///
/// # Panics
///
/// Never panics in practice: `GrantClaims` is a plain struct with no
/// fallible field types, so its own `serde_json` encoding step cannot fail.
#[must_use]
pub fn mint_conversation_grant_for_memory(
    signer: &TurnReadSigner,
    conversation_id: &str,
    subject: GrantSubject,
    expires_at_ms: u64,
    memory_sources: Vec<String>,
) -> String {
    let claims = GrantClaims {
        kind: GRANT_KIND.to_owned(),
        issuer: TurnReadRole::ISSUER.to_owned(),
        key_id: signer.identity().key_id().to_owned(),
        conversation_id: conversation_id.to_owned(),
        subject,
        expires_at_ms,
        memory_sources,
    };
    // `GrantClaims` is a plain, always-serializable struct (no maps, no
    // fallible field types) — encoding it can never fail.
    let canonical = serde_json::to_vec(&claims).expect("GrantClaims always serializes");
    let signature = signer.sign_turn_read_capability(&canonical);
    format!(
        "{}.{}",
        URL_SAFE_NO_PAD.encode(canonical),
        URL_SAFE_NO_PAD.encode(signature)
    )
}

/// Mint a [`GrantSubject::ControlFleet`] grant, signed by `signer` and valid
/// until `expires_at_ms`.
///
/// A dedicated entry point rather than a call to [`mint_conversation_grant`]
/// with an empty conversation id: this subject carries no conversation, and
/// exposing a `conversation_id` parameter here would invite exactly the
/// "which empty string means what" confusion the whole module's per-subject
/// split exists to avoid. Internally shares [`mint_conversation_grant`]'s
/// signing path — same signer role, same [`GRANT_KIND`], same wire shape —
/// so a verifier decodes it with the identical `GrantClaims` codec.
///
/// `statement_digest` is taken as an already-computed lowercase-hex string,
/// never raw bytes and never a SQL string: this crate has no SQL vocabulary
/// and no hashing dependency of its own, and computing the digest is the
/// caller's job (`crates/query/src/core_service.rs`'s `admit_control_fleet`
/// does the matching computation on the verifying side).
///
/// # Panics
///
/// Never panics in practice — see [`mint_conversation_grant`]'s own doc.
#[must_use]
pub fn mint_control_fleet_grant(
    signer: &TurnReadSigner,
    purpose: &str,
    statement_digest: &str,
    expires_at_ms: u64,
) -> String {
    mint_conversation_grant(
        signer,
        "",
        GrantSubject::ControlFleet {
            purpose: purpose.to_owned(),
            statement_digest: statement_digest.to_owned(),
        },
        expires_at_ms,
    )
}

/// Mint a [`GrantSubject::AdminFleet`] grant, signed by `signer` and valid
/// until `expires_at_ms` (PR 10A, 2026-09-18).
///
/// A dedicated entry point rather than a call to [`mint_conversation_grant`]
/// with an empty conversation id, for the identical reason
/// [`mint_control_fleet_grant`]'s own doc gives. Unlike that sibling, this
/// shape binds no statement digest at all — the caller has not typed the SQL
/// yet at mint time — so a verifier's ONLY per-query check is that
/// `admin_persona` is CURRENTLY admin, re-read fresh rather than trusted from
/// mint time (`crates/query/src/credential.rs`'s `scoping_for` does that
/// read on the verifying side). `session` rides along so the query-audit
/// requester names both the acting admin persona and the session, never just
/// one.
///
/// # Panics
///
/// Never panics in practice — see [`mint_conversation_grant`]'s own doc.
#[must_use]
pub fn mint_admin_fleet_grant(
    signer: &TurnReadSigner,
    admin_persona: &str,
    session: &str,
    expires_at_ms: u64,
) -> String {
    mint_conversation_grant(
        signer,
        "",
        GrantSubject::AdminFleet {
            admin_persona: admin_persona.to_owned(),
            session: session.to_owned(),
        },
        expires_at_ms,
    )
}

/// Mint a persona-WIDE [`GrantSubject::Persona`] grant.
///
/// Scoped to `persona_id`'s own participated conversations — every one of
/// them, never a single conversation and never the fleet. Signed by `signer`
/// and valid until `expires_at_ms`.
///
/// A dedicated entry point rather than a call to [`mint_conversation_grant`]
/// with an empty conversation id, for the identical reason
/// [`mint_control_fleet_grant`]'s own doc gives: this shape carries no SINGLE
/// conversation, and exposing a `conversation_id` parameter here would invite
/// the same "which empty string means what" confusion. Use
/// [`mint_conversation_grant`] directly with a real conversation id for the
/// OTHER `GrantSubject::Persona` shape (a routine owner reading one fire).
///
/// # Panics
///
/// Never panics in practice — see [`mint_conversation_grant`]'s own doc.
#[must_use]
pub fn mint_persona_wide_grant(
    signer: &TurnReadSigner,
    persona_id: &str,
    expires_at_ms: u64,
) -> String {
    mint_conversation_grant(
        signer,
        "",
        GrantSubject::Persona(persona_id.to_owned()),
        expires_at_ms,
    )
}