Skip to main content

astrid_core/
kernel_api.rs

1//! Kernel management API request and response types.
2//!
3//! These types describe the CLI ↔ daemon RPC surface (admin requests,
4//! status queries, capsule lifecycle ops). They live in `astrid-core`
5//! because they reference `PrincipalId` and `Quotas` from this crate.
6//!
7//! Capsule-facing IPC types live in `astrid-types` (which intentionally
8//! has no dependency on `astrid-core` — it must compile on
9//! `wasm32-unknown-unknown` without dragging in the kernel).
10
11use crate::PrincipalId;
12use crate::profile::Quotas;
13use serde::{Deserialize, Serialize};
14
15/// The well-known system session UUID string used by the background daemon.
16///
17/// All kernel-internal IPC messages are published with this `source_id`.
18/// WASM capsules that verify message provenance should compare against
19/// this constant. Mirrors `astrid_core::SessionId::SYSTEM`.
20pub const SYSTEM_SESSION_UUID: &str = "00000000-0000-0000-0000-000000000000";
21
22/// Management API requests directed at the core daemon.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(tag = "method", content = "params")]
25pub enum KernelRequest {
26    /// Request to install a capsule from a local or remote path.
27    InstallCapsule {
28        /// The path or URL to the `.capsule` archive.
29        source: String,
30        /// True if this should be installed locally in the workspace.
31        workspace: bool,
32    },
33    /// Request to approve a capability grant (usually following an `ApprovalNeeded` response).
34    ApproveCapability {
35        /// The unique ID of the request being approved.
36        request_id: String,
37        /// Cryptographic signature proving Root Identity authorization.
38        signature: String,
39    },
40    /// Request the list of currently loaded capsules.
41    ListCapsules,
42    /// Reload all capsules from the file system.
43    ReloadCapsules,
44    /// Request the list of globally registered slash commands.
45    GetCommands,
46    /// Request metadata about loaded capsules (manifests, providers, interceptors).
47    /// The kernel's equivalent of `/proc` — exposing process table info.
48    GetCapsuleMetadata,
49    /// Request the daemon to shut down gracefully.
50    Shutdown {
51        /// Optional reason for shutdown.
52        reason: Option<String>,
53    },
54    /// Request daemon status information.
55    GetStatus,
56}
57
58/// Management API responses from the core daemon.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(tag = "status", content = "data")]
61pub enum KernelResponse {
62    /// The request succeeded.
63    Success(serde_json::Value),
64    /// A list of available slash commands across all capsules.
65    Commands(Vec<CommandInfo>),
66    /// Metadata about loaded capsules.
67    CapsuleMetadata(Vec<CapsuleMetadataEntry>),
68    /// The request failed.
69    Error(String),
70    /// Daemon status information.
71    Status(DaemonStatus),
72    /// The request requires user capability approval before it can proceed.
73    ApprovalRequired {
74        /// Unique ID for this specific action request.
75        request_id: String,
76        /// Description of what is being requested.
77        description: String,
78        /// The specific capabilities required (e.g. `["host_process", "fs_write"]`).
79        capabilities: Vec<String>,
80    },
81}
82
83/// Daemon runtime status information.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct DaemonStatus {
86    /// Process ID of the daemon.
87    pub pid: u32,
88    /// Daemon uptime in seconds.
89    pub uptime_secs: u64,
90    /// Daemon version string.
91    pub version: String,
92    /// Whether the daemon is running in ephemeral mode.
93    pub ephemeral: bool,
94    /// Number of currently connected clients.
95    pub connected_clients: u32,
96    /// Per-principal breakdown of `connected_clients`. Each entry is
97    /// `(principal, count)`; the sum equals `connected_clients`. Empty
98    /// on daemons that don't yet expose per-principal connection
99    /// attribution (older builds, or when no clients are connected).
100    /// Used by `astrid who` to show who is actually on the daemon
101    /// rather than the bare count.
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub connections_by_principal: Vec<PrincipalConnectionCount>,
104    /// Names of loaded capsules.
105    pub loaded_capsules: Vec<String>,
106}
107
108/// Per-principal connection count entry on [`DaemonStatus`].
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct PrincipalConnectionCount {
111    /// The principal (agent) holding the connections.
112    pub principal: String,
113    /// Number of active connections owned by this principal.
114    pub count: u32,
115}
116
117/// Metadata entry for a loaded capsule.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct CapsuleMetadataEntry {
120    /// The capsule's unique name.
121    pub name: String,
122    /// Interceptor event patterns declared by this capsule.
123    pub interceptor_events: Vec<String>,
124}
125
126/// Information about a registered slash command.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct CommandInfo {
129    /// The slash command trigger (e.g. `/git`).
130    pub name: String,
131    /// A brief description of what the command does.
132    pub description: String,
133    /// The capsule that provides this command.
134    pub provider_capsule: String,
135}
136
137// ---------------------------------------------------------------------------
138// Admin management API (issue #672 — Layer 6)
139// ---------------------------------------------------------------------------
140
141/// Admin management API request wrapper carrying an optional client
142/// correlation ID and the typed request kind.
143///
144/// `request_id` is echoed back on [`AdminKernelResponse::request_id`] so
145/// clients with multiple in-flight requests on the same response topic
146/// can disambiguate. Single-client deployments may leave it `None`.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct AdminKernelRequest {
149    /// Optional client-supplied correlation ID. Echoed verbatim on the
150    /// response.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub request_id: Option<String>,
153    /// The typed request body — `tag = "method", content = "params"`.
154    #[serde(flatten)]
155    pub kind: AdminRequestKind,
156}
157
158impl AdminKernelRequest {
159    /// Build a request with no correlation ID.
160    #[must_use]
161    pub const fn new(kind: AdminRequestKind) -> Self {
162        Self {
163            request_id: None,
164            kind,
165        }
166    }
167
168    /// Build a request with a correlation ID.
169    #[must_use]
170    pub fn with_request_id(request_id: impl Into<String>, kind: AdminRequestKind) -> Self {
171        Self {
172            request_id: Some(request_id.into()),
173            kind,
174        }
175    }
176}
177
178impl From<AdminRequestKind> for AdminKernelRequest {
179    fn from(kind: AdminRequestKind) -> Self {
180        Self::new(kind)
181    }
182}
183
184/// Typed admin request body — flattened into [`AdminKernelRequest`] on
185/// the wire as `{ "method": "...", "params": {...} }`.
186///
187/// Every variant is gated by the Layer 5 capability-enforcement preamble
188/// through a sibling of
189/// [`required_capability`](../../astrid-kernel/src/kernel_router.rs) —
190/// see `required_capability_for_admin_request` for the exact mapping.
191/// Mutating variants are serialized through the kernel's admin write lock
192/// so concurrent callers cannot interleave on `groups.toml` / `profile.toml`.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194#[serde(tag = "method", content = "params")]
195pub enum AdminRequestKind {
196    /// Create a new agent identity. `name` must pass
197    /// [`PrincipalId::new`](astrid_core::PrincipalId::new). Defaults to
198    /// the built-in `agent` group when `groups` is empty.
199    AgentCreate {
200        /// Human-readable name and principal identifier for the new agent.
201        name: String,
202        /// Group memberships for the new principal; empty → `["agent"]`.
203        #[serde(default)]
204        groups: Vec<String>,
205        /// Per-principal capability grants beyond group inheritance.
206        #[serde(default)]
207        grants: Vec<String>,
208    },
209    /// Delete an existing agent identity. The `default` principal is
210    /// rejected unconditionally. The principal's home directory is NOT
211    /// scrubbed — reclamation is an ops concern.
212    AgentDelete {
213        /// Principal to delete.
214        principal: PrincipalId,
215    },
216    /// Set `enabled = true` on the target principal's profile.
217    AgentEnable {
218        /// Principal to enable.
219        principal: PrincipalId,
220    },
221    /// Set `enabled = false` on the target principal's profile.
222    /// In-flight invocations finish under the old value; new invocations
223    /// are refused.
224    AgentDisable {
225        /// Principal to disable.
226        principal: PrincipalId,
227    },
228    /// List every agent principal with a profile on disk.
229    AgentList,
230    /// Partial-update an existing agent's group memberships. Built-in
231    /// group names (`admin`, `agent`, `restricted`) and custom groups
232    /// loaded from `groups.toml` are both accepted as identifiers;
233    /// validation that the named groups exist happens at the new
234    /// profile's `validate` step. Mutations are idempotent — adding an
235    /// already-present group or removing an absent one is a no-op.
236    AgentModify {
237        /// Principal to modify.
238        principal: PrincipalId,
239        /// Groups to add (idempotent).
240        #[serde(default)]
241        add_groups: Vec<String>,
242        /// Groups to remove (idempotent — missing entries are no-ops).
243        /// Removing the last group leaves the agent in zero groups,
244        /// which the `agent` built-in does NOT auto-restore; operators
245        /// who want a baseline should add `agent` explicitly.
246        #[serde(default)]
247        remove_groups: Vec<String>,
248    },
249    /// Replace the target principal's [`Quotas`] block. Values are
250    /// validated before the atomic profile write.
251    QuotaSet {
252        /// Principal whose quotas are being set.
253        principal: PrincipalId,
254        /// Replacement quota values.
255        quotas: Quotas,
256    },
257    /// Read the target principal's current [`Quotas`] block.
258    QuotaGet {
259        /// Principal whose quotas are being read.
260        principal: PrincipalId,
261    },
262    /// Read the target principal's current resource **usage** vs budget —
263    /// the cross-capsule CPU total plus the configured ceilings. Read-only,
264    /// scoped exactly like [`QuotaGet`](Self::QuotaGet) (`self:quota:get` /
265    /// `quota:get`): a principal can read its own usage, an admin can read
266    /// anyone's.
267    UsageGet {
268        /// Principal whose usage is being read.
269        principal: PrincipalId,
270    },
271    /// Create a custom group, validated through the same rules the boot
272    /// loader applies to `groups.toml`.
273    GroupCreate {
274        /// Name of the new custom group.
275        name: String,
276        /// Capability patterns conferred by the new group.
277        capabilities: Vec<String>,
278        /// Human-readable description.
279        #[serde(default)]
280        description: Option<String>,
281        /// Required when `capabilities` contains the universal `*` pattern.
282        #[serde(default)]
283        unsafe_admin: bool,
284    },
285    /// Remove a custom group. Built-in groups (`admin`, `agent`,
286    /// `restricted`) are rejected.
287    GroupDelete {
288        /// Name of the group to remove.
289        name: String,
290    },
291    /// Partial-update a custom group. Every provided field replaces the
292    /// corresponding field on the existing group. Built-ins are rejected.
293    GroupModify {
294        /// Name of the group to modify.
295        name: String,
296        /// New capability patterns, if changing.
297        #[serde(default)]
298        capabilities: Option<Vec<String>>,
299        /// New description, if changing. Outer `None` = keep, inner
300        /// `None` = clear.
301        #[serde(default)]
302        description: Option<Option<String>>,
303        /// New `unsafe_admin` flag, if changing.
304        #[serde(default)]
305        unsafe_admin: Option<bool>,
306    },
307    /// List every group (built-in + custom) with its capability set.
308    GroupList,
309    /// Append capability patterns to the principal's `grants` vec. Does
310    /// NOT clear matching revokes — revoke precedence is preserved.
311    CapsGrant {
312        /// Principal receiving the grants.
313        principal: PrincipalId,
314        /// Capability patterns to add.
315        capabilities: Vec<String>,
316        /// Required when `capabilities` contains the universal `*`
317        /// pattern. Mirrors the `unsafe_admin` rail on
318        /// [`Self::GroupCreate`] / [`Self::GroupModify`] so an
319        /// individual grant cannot escalate a principal to universal
320        /// admin without an explicit acknowledgement.
321        #[serde(default)]
322        unsafe_admin: bool,
323    },
324    /// Append capability patterns to the principal's `revokes` vec. Safe
325    /// to call on caps the principal does not currently hold
326    /// (pre-emptive revoke).
327    CapsRevoke {
328        /// Principal losing the capabilities.
329        principal: PrincipalId,
330        /// Capability patterns to revoke.
331        capabilities: Vec<String>,
332    },
333    /// Issue a new invite token. Capability-gated by `invite:issue`.
334    /// The kernel persists the token under `etc/invites.toml` with
335    /// expiry + remaining use count, and the caller publishes the
336    /// returned redeem URL out-of-band.
337    InviteIssue {
338        /// Group new redeemers join. Must already exist (built-in or
339        /// custom) — validated against the live `GroupConfig`.
340        group: String,
341        /// Seconds until the token expires. `None` = no expiry (the
342        /// max-uses counter is the only stop). Capped server-side to
343        /// 30 days to bound forever-tokens.
344        #[serde(default, skip_serializing_if = "Option::is_none")]
345        expires_secs: Option<u64>,
346        /// Maximum number of successful redemptions before the token is
347        /// invalidated. Zero is rejected (issuing a dead token serves
348        /// no purpose).
349        max_uses: u32,
350        /// Free-form short label (e.g. "alice's tablet") attached to
351        /// the persisted record. Surfaced by `InviteList`.
352        #[serde(default, skip_serializing_if = "Option::is_none")]
353        metadata: Option<String>,
354    },
355    /// Redeem an invite token. The token IS the auth: the kernel-side
356    /// dispatcher special-cases this variant to skip the capability
357    /// preamble (the caller principal does not yet exist), and the
358    /// handler verifies the token, mints a fresh principal via the
359    /// existing `AgentCreate` machinery, registers the supplied
360    /// ed25519 public key on the new principal's profile, and decrements
361    /// the token's use counter (deleting the record on the last use).
362    InviteRedeem {
363        /// Opaque token bytes (URL-safe base64) returned from a prior
364        /// `InviteIssue`.
365        token: String,
366        /// Hex-encoded ed25519 public key (32 bytes / 64 hex chars).
367        /// Registered on the new principal's `AuthConfig.public_keys`.
368        public_key: String,
369        /// Optional human-friendly name attached to the minted principal.
370        /// When `Some(s)`, the kernel generates the underlying
371        /// `PrincipalId` from `s` (slugified, collision-checked); when
372        /// `None`, a random `agent-<8-hex>` id is allocated.
373        #[serde(default, skip_serializing_if = "Option::is_none")]
374        display_name: Option<String>,
375    },
376    /// List outstanding invite tokens. Gated by `invite:list`.
377    InviteList,
378    /// Revoke an outstanding invite token without consuming it.
379    /// Gated by `invite:revoke`.
380    InviteRevoke {
381        /// The opaque token to invalidate.
382        token: String,
383    },
384    /// Issue a pair-device token. Gated by `self:auth:pair` (the
385    /// caller can only mint pair-tokens for their own principal —
386    /// the kernel ignores any target field on the wire and ties the
387    /// token to the caller). Used to add a new device's ed25519
388    /// public key to an existing principal's `AuthConfig.public_keys`
389    /// without minting a separate principal.
390    PairDeviceIssue {
391        /// Seconds until the token expires. Capped server-side to
392        /// 1 hour — pair-tokens are intended for immediate use on a
393        /// neighbouring device, not for long-lived sharing.
394        #[serde(default, skip_serializing_if = "Option::is_none")]
395        expires_secs: Option<u64>,
396        /// Free-form short label (e.g. "alice's phone") persisted
397        /// alongside the new public key on
398        /// `AuthConfig.public_keys` once the token is redeemed.
399        #[serde(default, skip_serializing_if = "Option::is_none")]
400        label: Option<String>,
401    },
402    /// Redeem a pair-device token. Like `InviteRedeem`, the kernel
403    /// dispatcher special-cases this to bypass the capability
404    /// preamble — the token IS the auth. The handler verifies the
405    /// token, appends the supplied public key to the issuing
406    /// principal's `AuthConfig.public_keys`, and decrements / deletes
407    /// the token record.
408    PairDeviceRedeem {
409        /// The opaque token from a prior `PairDeviceIssue`.
410        token: String,
411        /// Hex-encoded ed25519 public key (32 bytes / 64 hex chars).
412        public_key: String,
413    },
414}
415
416/// Admin management API response wrapper carrying the echoed
417/// correlation ID and the typed response body.
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct AdminKernelResponse {
420    /// Echoed `request_id` from the [`AdminKernelRequest`] this response
421    /// answers. `None` when the client did not provide one.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub request_id: Option<String>,
424    /// The typed response body — `tag = "status", content = "data"`.
425    #[serde(flatten)]
426    pub body: AdminResponseBody,
427}
428
429impl AdminKernelResponse {
430    /// Build a response with the given body and no correlation ID.
431    #[must_use]
432    pub const fn new(body: AdminResponseBody) -> Self {
433        Self {
434            request_id: None,
435            body,
436        }
437    }
438
439    /// Build a response that echoes a request's correlation ID.
440    #[must_use]
441    pub fn for_request(request_id: Option<String>, body: AdminResponseBody) -> Self {
442        Self { request_id, body }
443    }
444}
445
446/// Typed admin response body.
447#[derive(Debug, Clone, Serialize, Deserialize)]
448#[serde(tag = "status", content = "data")]
449pub enum AdminResponseBody {
450    /// Generic success payload — used by mutating variants where the
451    /// interesting result is "the write landed."
452    Success(serde_json::Value),
453    /// Response for [`AdminRequestKind::AgentList`].
454    AgentList(Vec<AgentSummary>),
455    /// Response for [`AdminRequestKind::GroupList`].
456    GroupList(Vec<GroupSummary>),
457    /// Response for [`AdminRequestKind::QuotaGet`].
458    Quotas(Quotas),
459    /// Response for [`AdminRequestKind::UsageGet`].
460    Usage(ResourceUsage),
461    /// Response for [`AdminRequestKind::InviteIssue`] — the freshly
462    /// minted token plus its persisted metadata. The redemption URL is
463    /// derived client-side from the deployment's public gateway base
464    /// URL; the kernel never knows where the gateway is reachable.
465    Invite(InviteIssued),
466    /// Response for [`AdminRequestKind::InviteRedeem`] — the new
467    /// principal id (so the redeemer can locally pin the binding) and
468    /// the assigned group. The redeemer also gets back the issuing
469    /// public-key fingerprint so out-of-band verification of the
470    /// minted principal becomes possible.
471    InviteRedeemed(InviteRedeemed),
472    /// Response for [`AdminRequestKind::InviteList`].
473    InviteList(Vec<InviteSummary>),
474    /// Response for [`AdminRequestKind::PairDeviceIssue`].
475    PairToken(PairTokenIssued),
476    /// Response for [`AdminRequestKind::PairDeviceRedeem`].
477    PairTokenRedeemed(PairTokenRedeemed),
478    /// The request failed.
479    Error(String),
480}
481
482/// Per-principal resource usage vs configured budget — the payload of
483/// [`AdminRequestKind::UsageGet`], rendered by `astrid quota`/`astrid top` and
484/// `GET /api/sys/principals/{id}/usage` so per-principal usage is measurable.
485///
486/// **CPU** is the live cross-capsule aggregate: the kernel's shared fuel ledger
487/// sums every interceptor's exact wasmtime-fuel cost per invoking principal
488/// across all capsules. **Memory** is reported as a per-principal *peak*
489/// (`memory_bytes_peak_total`): the kernel's shared memory ledger records the
490/// high-water linear-memory size each invoking principal grows a Store to,
491/// max'd across all capsules. A live cross-capsule *current* total
492/// (`memory_bytes_current_total`) is not implemented — under pooled, shared
493/// Stores it is not cleanly attributable — so it stays `None`; the limit field
494/// reports the per-instance ceiling.
495#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct ResourceUsage {
497    /// Principal this usage report describes.
498    pub principal: PrincipalId,
499    /// Cumulative interceptor CPU burned across ALL capsules, in wasmtime fuel
500    /// units (exact deterministic instruction count, monotonic for the process
501    /// lifetime).
502    pub cpu_fuel_consumed_total: u64,
503    /// Configured CPU rate ceiling ([`Quotas::max_cpu_fuel_per_sec`]), always
504    /// `> 0` (validation rejects `0` — there is no "unlimited" sentinel;
505    /// unbounded CPU is a capability, surfaced by `exempt`).
506    pub cpu_fuel_per_sec_limit: u64,
507    /// Whether the principal is exempt from resource budgets — it holds
508    /// `system:resources:unbounded`, `net_bind`, or `uplink` (admins via `*`).
509    /// When `true` the limit fields are advisory, never enforced.
510    pub exempt: bool,
511    /// Per-capsule-instance memory ceiling ([`Quotas::max_memory_bytes`]). This
512    /// is a per-Store cap, not a cross-capsule total.
513    pub memory_bytes_limit_per_instance: u64,
514    /// Current cross-capsule resident memory total, or `None` — a live
515    /// "current" total is not cleanly attributable under pooled, shared Stores,
516    /// so the peak (below) is the reported memory signal instead.
517    pub memory_bytes_current_total: Option<u64>,
518    /// Peak cross-capsule linear-memory high-water mark this principal has
519    /// driven, in bytes, max'd across every capsule it invokes (from the shared
520    /// memory ledger). `None` while no peak has been recorded — including
521    /// single-tenant deployments before any guest grows memory. The principal
522    /// that *grows* a Store owns the peak; one reusing an already-grown Store
523    /// without growing is not charged.
524    pub memory_bytes_peak_total: Option<u64>,
525}
526
527/// Summary of an agent principal returned by
528/// [`AdminKernelRequest::AgentList`].
529#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
530pub struct AgentSummary {
531    /// The principal identifier.
532    pub principal: PrincipalId,
533    /// Whether the principal is currently enabled (master switch).
534    pub enabled: bool,
535    /// Group memberships as written to `profile.toml`.
536    pub groups: Vec<String>,
537    /// Direct capability grants beyond group inheritance.
538    pub grants: Vec<String>,
539    /// Explicit revokes (highest-precedence deny).
540    pub revokes: Vec<String>,
541}
542
543/// Response payload for [`AdminRequestKind::InviteIssue`].
544#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
545pub struct InviteIssued {
546    /// Opaque token (URL-safe base64). The caller delivers this to the
547    /// redeemer out-of-band — e.g. printed by the CLI, surfaced by the
548    /// gateway as a redeem URL fragment, or pasted into a chat.
549    pub token: String,
550    /// Group the redeemer will join on success.
551    pub group: String,
552    /// Number of remaining redemptions before the token is invalidated.
553    pub remaining_uses: u32,
554    /// Wall-clock Unix-epoch timestamp at which the token expires.
555    /// `None` when the issuer requested no expiry.
556    #[serde(default, skip_serializing_if = "Option::is_none")]
557    pub expires_at_epoch: Option<u64>,
558    /// Operator-supplied label (`metadata` from the issue request).
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub metadata: Option<String>,
561}
562
563/// Response payload for [`AdminRequestKind::InviteRedeem`].
564#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
565pub struct InviteRedeemed {
566    /// The freshly minted principal id. The redeemer pins this locally
567    /// alongside its keypair so subsequent gateway sessions can verify
568    /// the binding.
569    pub principal: PrincipalId,
570    /// Group the new principal is now a member of.
571    pub group: String,
572    /// SHA-256 fingerprint (hex) of the registered ed25519 public key.
573    /// Lets the redeemer verify that the kernel registered the key it
574    /// sent rather than substituting one of its own.
575    pub public_key_fingerprint: String,
576}
577
578/// Response payload for [`AdminRequestKind::PairDeviceIssue`].
579#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
580pub struct PairTokenIssued {
581    /// Opaque token. The issuing device hands this to the new
582    /// device out-of-band (QR code, NFC, manual copy).
583    pub token: String,
584    /// Principal the new device's key will attach to (always the
585    /// caller, never request-body derived).
586    pub principal: PrincipalId,
587    /// Wall-clock Unix-epoch timestamp at which the token expires.
588    pub expires_at_epoch: u64,
589    /// Operator-supplied label (echoed; not yet bound).
590    #[serde(default, skip_serializing_if = "Option::is_none")]
591    pub label: Option<String>,
592}
593
594/// Response payload for [`AdminRequestKind::PairDeviceRedeem`].
595#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
596pub struct PairTokenRedeemed {
597    /// The principal the new device is now bound to.
598    pub principal: PrincipalId,
599    /// SHA-256 fingerprint (hex) of the registered ed25519 key.
600    /// Lets the redeemer verify the kernel registered the key it
601    /// sent rather than substituting one of its own.
602    pub public_key_fingerprint: String,
603}
604
605/// Summary of an outstanding invite returned by
606/// [`AdminRequestKind::InviteList`].
607#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
608pub struct InviteSummary {
609    /// SHA-256 fingerprint (hex) of the token — the kernel does not
610    /// leak the raw token through list responses. Issuers retain the
611    /// raw value from the original [`InviteIssued`] response.
612    pub token_fingerprint: String,
613    /// Group the redeemer will join.
614    pub group: String,
615    /// Remaining redemptions.
616    pub remaining_uses: u32,
617    /// Wall-clock Unix-epoch timestamp at which the token expires.
618    #[serde(default, skip_serializing_if = "Option::is_none")]
619    pub expires_at_epoch: Option<u64>,
620    /// Wall-clock Unix-epoch timestamp at which the token was issued.
621    pub issued_at_epoch: u64,
622    /// Operator-supplied label.
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub metadata: Option<String>,
625}
626
627/// Summary of a group returned by [`AdminKernelRequest::GroupList`].
628#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
629pub struct GroupSummary {
630    /// Group name.
631    pub name: String,
632    /// Capability patterns conferred by this group.
633    pub capabilities: Vec<String>,
634    /// Human-readable description.
635    #[serde(default, skip_serializing_if = "Option::is_none")]
636    pub description: Option<String>,
637    /// Whether the group opted in to granting the universal `*`.
638    pub unsafe_admin: bool,
639    /// `true` for built-in groups (`admin`, `agent`, `restricted`).
640    /// Clients should treat built-ins as read-only.
641    pub builtin: bool,
642}