Skip to main content

mlua_swarm/
types.rs

1//! Fundamental types: Role / Verb / RoleVerbGate / CapToken / IDs.
2
3use hmac::{Hmac, Mac};
4use serde::{Deserialize, Serialize};
5use sha2::Sha256;
6use std::collections::{HashMap, HashSet};
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9// ─── ID newtypes ───────────────────────────────────────────────────────────
10
11/// Error returned when an ID string does not carry the expected prefix.
12///
13/// Produced by the fallible constructors on the ID newtypes
14/// ([`StepId::parse`], [`TaskId::parse`], ...) and by their serde
15/// `Deserialize` impls (which route through `TryFrom<String>`), so a
16/// misrouted or malformed id fails at the boundary instead of deep inside
17/// a store lookup.
18#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
19#[error("invalid {kind} id `{got}`: expected `{expected}` prefix")]
20pub struct IdParseError {
21    /// Human-readable ID kind (`"step"`, `"task"`, ...).
22    pub kind: &'static str,
23    /// The prefix this ID kind requires, e.g. `ST-`.
24    pub expected: &'static str,
25    /// The rejected input.
26    pub got: String,
27}
28
29/// Defines a prefix-validated ID newtype.
30///
31/// The generated type keeps its inner `String` **private**: the only ways
32/// to obtain a value are `new()` (mint) and `parse()` / `TryFrom<String>` /
33/// `FromStr` (validated) — which is what makes the prefix check impossible
34/// to bypass at call sites. Serde deserialization routes through
35/// `TryFrom<String>` (`#[serde(try_from = "String")]`), and serialization
36/// stays the plain inner string, so the wire format is byte-for-byte
37/// unchanged from the `pub String` era.
38macro_rules! id_newtype {
39    ($(#[$meta:meta])* $name:ident, $prefix:literal, $kind:literal) => {
40        $(#[$meta])*
41        #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42        #[serde(try_from = "String")]
43        pub struct $name(String);
44
45        impl $name {
46            /// The prefix this ID kind carries on the wire.
47            pub const PREFIX: &'static str = $prefix;
48
49            /// Mint a fresh id with the kind prefix and a process-unique
50            /// nonce (see [`uid_hex`]).
51            pub fn new() -> Self {
52                Self(format!(concat!($prefix, "{}"), uid_hex(8)))
53            }
54
55            /// Parse an externally-supplied string, rejecting values that
56            /// do not start with the kind prefix or carry nothing after it.
57            pub fn parse(s: impl Into<String>) -> Result<Self, IdParseError> {
58                let s = s.into();
59                if s.len() > $prefix.len() && s.starts_with($prefix) {
60                    Ok(Self(s))
61                } else {
62                    Err(IdParseError {
63                        kind: $kind,
64                        expected: $prefix,
65                        got: s,
66                    })
67                }
68            }
69
70            /// View the id as a string slice.
71            pub fn as_str(&self) -> &str {
72                &self.0
73            }
74
75            /// Consume the id and return the inner string.
76            pub fn into_string(self) -> String {
77                self.0
78            }
79        }
80
81        impl Default for $name {
82            fn default() -> Self {
83                Self::new()
84            }
85        }
86
87        impl std::fmt::Display for $name {
88            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89                f.write_str(&self.0)
90            }
91        }
92
93        impl TryFrom<String> for $name {
94            type Error = IdParseError;
95            fn try_from(s: String) -> Result<Self, IdParseError> {
96                Self::parse(s)
97            }
98        }
99
100        impl std::str::FromStr for $name {
101            type Err = IdParseError;
102            fn from_str(s: &str) -> Result<Self, IdParseError> {
103                Self::parse(s)
104            }
105        }
106
107        impl AsRef<str> for $name {
108            fn as_ref(&self) -> &str {
109                &self.0
110            }
111        }
112
113        impl From<$name> for String {
114            fn from(id: $name) -> String {
115                id.0
116            }
117        }
118    };
119}
120
121id_newtype!(
122    /// Opaque per-step identifier, e.g. `ST-<hex>`. Newtype over `String` so
123    /// step, session, and worker ids can't be swapped by accident at call
124    /// sites.
125    ///
126    /// One `StepId` is minted per dispatched Blueprint step (the engine's
127    /// dispatcher "spins up a fresh task per `Step.ref`"). It is scoped to a
128    /// single step execution — the whole-kick identity is [`RunId`], and the
129    /// work-item identity is [`TaskId`].
130    ///
131    /// Renamed from `TaskId` (`T-` prefix) in the issue #13 ID-hierarchy
132    /// reconciliation: Blueprint → Task → Run → Step → Attempt.
133    StepId, "ST-", "step"
134);
135
136id_newtype!(
137    /// Opaque work-item identifier, e.g. `T-<hex>`. One `TaskId` names one
138    /// unit of work ("resolve issue #10" + a Blueprint ref + input ctx),
139    /// persisted in the task store. A task can be kicked N times; each kick
140    /// is a [`RunId`].
141    ///
142    /// Not to be confused with [`StepId`] (the per-step id that carried the
143    /// `TaskId` name before issue #13).
144    TaskId, "T-", "task"
145);
146
147id_newtype!(
148    /// Opaque run identifier, e.g. `R-<hex>`. One `RunId` names one kick of a
149    /// [`TaskId`] — minted server-side when a task is started, propagated
150    /// through the engine ctx to every wire frame so steps, workers, and
151    /// outputs correlate back to the run.
152    ///
153    /// The `R-` prefix is reserved for run ids; the engine's resume keys
154    /// moved to `RK-` in issue #14 so the two can't shadow each other under
155    /// prefix validation.
156    RunId, "R-", "run"
157);
158
159id_newtype!(
160    /// Opaque session identifier, e.g. `S-<hex>`. See [`StepId`] for the
161    /// newtype rationale.
162    ///
163    /// This is the one session-id shape across the system (issue #11): the
164    /// engine mints it for attached operator sessions, and the server's
165    /// `POST /v1/operators` login path mints the WS operator `sid` in the
166    /// same shape (the old `op-<uuid>` sid form is retired). A `SessionId`
167    /// is an identifier, not a credential — bearer secrets use `secure_hex`
168    /// tokens.
169    SessionId, "S-", "session"
170);
171
172id_newtype!(
173    /// Opaque worker identifier, e.g. `W-<hex>`. See [`StepId`] for the
174    /// newtype rationale.
175    WorkerId, "W-", "worker"
176);
177
178// ─── Role × Verb ───────────────────────────────────────────────────────────
179
180/// The four participant roles in the swarm. Every [`Verb`] a caller wants to
181/// invoke must be allow-listed for its role in a [`RoleVerbGate`].
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum Role {
185    /// Drives task lifecycle: starts tasks, dispatches attempts, reads
186    /// state, manages sessions.
187    Operator,
188    /// Executes a dispatched attempt: fetches its prompt/data, posts a
189    /// result, verifies its own token.
190    Worker,
191    /// Read-only: subscribes to events and reads trace/state without
192    /// mutating anything.
193    Observer,
194    /// Human/oversight role: answers queries, overrides verdicts, and can
195    /// pause/resume the loop or inject a directive.
196    Senior,
197}
198
199/// Every action a participant can request. Grouped by the [`Role`] that
200/// typically performs it (see the `// operator` / `// worker` / ... section
201/// comments below); the grouping is documentation only — actual
202/// authorization is decided by [`RoleVerbGate::is_allowed`].
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum Verb {
206    // operator
207    /// Create a new task.
208    StartTask,
209    /// Dispatch (or re-dispatch) an attempt for a task.
210    DispatchAttempt,
211    /// Mint a [`CapToken`] for a worker.
212    MintWorkerToken,
213    /// Read the current state of a task.
214    ReadTaskState,
215    /// Cancel a task.
216    CancelTask,
217    /// Ask a [`Role::Senior`] a question about a task.
218    QuerySenior,
219    /// Mark a task/attempt as passed.
220    MarkPass,
221    /// Mark a task/attempt as blocked.
222    MarkBlocked,
223    /// Attach a session to a task.
224    AttachSession,
225    /// Detach a session from a task.
226    DetachSession,
227    /// Emit a liveness heartbeat.
228    Heartbeat,
229    /// Poll for task progress/completion.
230    PollTask,
231    // worker
232    /// Fetch the rendered prompt for the current attempt.
233    FetchPrompt,
234    /// Fetch task input data.
235    FetchData,
236    /// Post the result of an attempt.
237    PostResult,
238    /// Verify a presented [`CapToken`].
239    VerifyToken,
240    /// Emit intermediate output for observers.
241    EmitOutput,
242    // observer
243    /// Subscribe to the task's event stream.
244    SubscribeEvents,
245    /// Read the accumulated trace of a task.
246    ReadTrace,
247    // senior
248    /// Answer a query raised via [`Verb::QuerySenior`].
249    AnswerQuery,
250    /// Override a previously recorded verdict.
251    OverrideVerdict,
252    /// Pause the dispatch loop.
253    PauseLoop,
254    /// Resume a paused dispatch loop.
255    ResumeLoop,
256    /// Inject a directive into a running task.
257    InjectDirective,
258}
259
260/// Role × Verb gate table. Const-style storage.
261#[derive(Debug, Clone)]
262pub struct RoleVerbGate {
263    table: HashMap<Role, HashSet<Verb>>,
264}
265
266impl RoleVerbGate {
267    /// Build an empty gate (nothing allowed until [`Self::allow`] is called).
268    pub fn new() -> Self {
269        Self {
270            table: HashMap::new(),
271        }
272    }
273
274    /// Allow-list `verbs` for `role`, merging with any existing entries.
275    /// Returns `self` for chained construction (see
276    /// [`default_role_verb_table`]).
277    pub fn allow(mut self, role: Role, verbs: &[Verb]) -> Self {
278        let set = self.table.entry(role).or_default();
279        for v in verbs {
280            set.insert(*v);
281        }
282        self
283    }
284
285    /// Whether `role` is allow-listed to invoke `verb`.
286    pub fn is_allowed(&self, role: Role, verb: Verb) -> bool {
287        self.table
288            .get(&role)
289            .map(|s| s.contains(&verb))
290            .unwrap_or(false)
291    }
292}
293
294impl Default for RoleVerbGate {
295    fn default() -> Self {
296        default_role_verb_table()
297    }
298}
299
300// ─── Verb tables (const slices, swap-out points for future Role splits) ──
301
302/// Verbs an Operator may invoke — covers task lifecycle, session, and
303/// senior interactions.
304pub const OPERATOR_VERBS: &[Verb] = &[
305    Verb::StartTask,
306    Verb::DispatchAttempt,
307    Verb::MintWorkerToken,
308    Verb::ReadTaskState,
309    Verb::CancelTask,
310    Verb::QuerySenior,
311    Verb::MarkPass,
312    Verb::MarkBlocked,
313    Verb::AttachSession,
314    Verb::DetachSession,
315    Verb::Heartbeat,
316    Verb::PollTask,
317];
318
319/// The Worker verbs shared across all workers — the minimum a leaf
320/// needs, with no sub-task spawning. If we introduce
321/// `Role::WorkerLeaf` in the future, that role gets allowed against
322/// this slice.
323pub const WORKER_LEAF_VERBS: &[Verb] = &[
324    Verb::FetchPrompt,
325    Verb::FetchData,
326    Verb::PostResult,
327    Verb::VerifyToken,
328    Verb::EmitOutput,
329];
330
331/// Worker verbs for recursive swarming: sub-task spawn and
332/// observation. When `Role::WorkerSwarm` splits out in the future,
333/// that role gets allowed against `WORKER_LEAF_VERBS` plus this
334/// slice. The safety valves are `EngineCfg.max_spawn_depth` today,
335/// and a task-ownership gate down the line.
336pub const WORKER_SWARM_VERBS: &[Verb] = &[
337    Verb::StartTask,
338    Verb::DispatchAttempt,
339    Verb::ReadTaskState,
340    Verb::PollTask,
341    Verb::CancelTask,
342];
343
344/// Verbs an Observer may invoke — strictly read-only (event subscription
345/// and trace/state reads, no mutation).
346pub const OBSERVER_VERBS: &[Verb] = &[Verb::SubscribeEvents, Verb::ReadTrace, Verb::ReadTaskState];
347
348/// Verbs a Senior may invoke — human/oversight actions: answering
349/// queries, overriding verdicts, and pausing/resuming/injecting into the
350/// dispatch loop.
351pub const SENIOR_VERBS: &[Verb] = &[
352    Verb::AnswerQuery,
353    Verb::OverrideVerdict,
354    Verb::PauseLoop,
355    Verb::ResumeLoop,
356    Verb::InjectDirective,
357];
358
359/// The default Role × Verb table.
360///
361/// Today `Role::Worker` holds both leaf and swarm capabilities. When
362/// we split it into `WorkerLeaf` / `WorkerSwarm` in the future, the
363/// only change needed is swapping the `allow(Role::Worker, ...)` line
364/// here for two lines — the verb slices themselves stay `const` and
365/// get reused as-is.
366pub fn default_role_verb_table() -> RoleVerbGate {
367    RoleVerbGate::new()
368        .allow(Role::Operator, OPERATOR_VERBS)
369        .allow(Role::Worker, WORKER_LEAF_VERBS)
370        .allow(Role::Worker, WORKER_SWARM_VERBS)
371        .allow(Role::Observer, OBSERVER_VERBS)
372        .allow(Role::Senior, SENIOR_VERBS)
373}
374
375// ─── CapToken ──────────────────────────────────────────────────────────────
376
377/// Capability token. `max_uses` picks between OneTime / Session /
378/// Limited.
379///
380/// The `uses_left` counter is **server-side, on `EngineState`**: the
381/// token stays immutable, and the record holds the counter.
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383pub struct CapToken {
384    /// Identifier of the agent this token was minted for.
385    pub agent_id: String,
386    /// The [`Role`] the bearer is authorized to act as.
387    pub role: Role,
388    /// Free-form scope strings (interpretation is caller-defined; `"*"`
389    /// conventionally means unrestricted).
390    pub scopes: Vec<String>,
391    /// Unix timestamp (seconds) when the token was minted.
392    pub issued_at: u64,
393    /// Unix timestamp (seconds) after which the token is expired.
394    pub expire_at: u64,
395    /// Remaining-use budget: `None` = unlimited (session token), `Some(n)`
396    /// = at most `n` uses (one-time when `n == 1`).
397    pub max_uses: Option<u32>,
398    /// Random per-mint value — **secret material** (it rides inside
399    /// `encode()` and the `MSE_TOKEN_NONCE` env). The server-side lookup
400    /// key is [`CapToken::fingerprint`] (its SHA-256), never the nonce
401    /// itself (issue #14).
402    pub nonce: String,
403    /// Hex-encoded HMAC-SHA256 signature over [`CapToken::signing_input`].
404    pub sig_hex: String,
405}
406
407impl CapToken {
408    /// Server-side lookup key for this token: hex SHA-256 of the `nonce`.
409    ///
410    /// The nonce is the token's secret material, so the server never uses
411    /// it directly as a map key or prints it in diagnostics — the
412    /// fingerprint is the loggable identity (issue #14; the sibling
413    /// pattern is the operator login flow's sid / token split). Replaces
414    /// the former `id()` accessor, which returned the raw nonce.
415    pub fn fingerprint(&self) -> String {
416        token_fingerprint(&self.nonce)
417    }
418
419    /// Input for the HMAC signature — the concatenation of every field
420    /// except `sig` itself.
421    pub fn signing_input(&self) -> Vec<u8> {
422        let s = format!(
423            "{}|{:?}|{}|{}|{}|{:?}|{}",
424            self.agent_id,
425            self.role,
426            self.scopes.join(","),
427            self.issued_at,
428            self.expire_at,
429            self.max_uses,
430            self.nonce,
431        );
432        s.into_bytes()
433    }
434
435    /// Whether `now_unix` is at or past [`CapToken::expire_at`].
436    pub fn is_expired(&self, now_unix: u64) -> bool {
437        now_unix >= self.expire_at
438    }
439
440    /// Transport-safe string encoding — URL-safe base64 of the
441    /// `serde_json` representation. Used when SubAgents put the token
442    /// on the HTTP path via `Authorization: Bearer <encode()>`. The
443    /// HMAC signature covers every field, so the server verifies with
444    /// `verify_sig` after decoding.
445    pub fn encode(&self) -> String {
446        use base64::Engine as _;
447        let json = serde_json::to_vec(self).expect("CapToken is always JSON-serializable");
448        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json)
449    }
450
451    /// The inverse of `encode()`: base64 decode followed by JSON
452    /// parse. Either failure returns `CapTokenDecodeError` — this is
453    /// the input-validation step when the server receives a `Bearer`
454    /// token.
455    pub fn decode(s: &str) -> Result<Self, CapTokenDecodeError> {
456        use base64::Engine as _;
457        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
458            .decode(s)
459            .map_err(|e| CapTokenDecodeError::Base64(e.to_string()))?;
460        serde_json::from_slice(&bytes).map_err(|e| CapTokenDecodeError::Json(e.to_string()))
461    }
462}
463
464/// Response body for `HTTP /v1/worker/prompt` — the shape that lets a
465/// SubAgent pull its task input in a single round-trip.
466///
467/// - `system`: the rendered `AgentDef.profile.system_prompt` (`None`
468///   when the profile is absent, or when it was baked but is delivered
469///   by reference instead — see `system_ref` below).
470/// - `prompt`: `TaskSpec.initial_directive` rendered to `String` at
471///   this boundary (issue #18). The engine stores
472///   `initial_directive` as `Value` end-to-end
473///   (`EngineState.prompts` / `Engine::fetch_prompt`); the coercion
474///   to `String` (strings verbatim, anything else serde-stringified)
475///   happens here in `Engine::fetch_worker_payload*` because the
476///   `/v1/worker/prompt` HTTP wire format is a plain string.
477/// - `agent`: `TaskSpec.agent` — the agent name this dispatch is
478///   targeting.
479/// - `attempt`: the 1-based attempt number, matching the current
480///   `task.attempt`.
481/// - `context`: GH #20 Contract C — the materialized
482///   [`crate::core::agent_context::AgentContextView`] for this
483///   `(task_id, attempt)`, when `AgentContextMiddleware` was layered onto
484///   the spawner stack that dispatched it. `None` on pre-#20 payloads
485///   (backward compat) and whenever the middleware was never layered.
486/// - `system_ref`: GH #31 — populated instead of `system` when the baked
487///   `system_prompt` exceeds the server's configured size threshold. See
488///   [`SystemRef`].
489#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
490pub struct WorkerPayload {
491    /// The task this payload was fetched for. Typed [`StepId`] since issue
492    /// #14 — serde keeps the wire shape a plain string.
493    #[schemars(with = "String")]
494    pub task_id: StepId,
495    /// 1-based attempt number, matching the current `task.attempt`.
496    pub attempt: u32,
497    /// Name of the agent this dispatch is targeting.
498    pub agent: String,
499    /// Rendered system prompt, if the agent profile defines one.
500    #[serde(skip_serializing_if = "Option::is_none")]
501    pub system: Option<String>,
502    /// The task's initial directive, baked in at dispatch preparation.
503    pub prompt: String,
504    /// GH #20 Contract C: the materialized task-level context view — see
505    /// the struct doc above.
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    pub context: Option<crate::core::agent_context::AgentContextView>,
508    /// GH #31: by-reference alternative to `system` when the baked
509    /// `system_prompt` exceeds the server's `SystemRefConfig.threshold_bytes`.
510    /// Exactly one of `system` / `system_ref` is ever `Some` when a
511    /// `system_prompt` was baked for this dispatch — never both `Some`,
512    /// never both `None` in that case. Absent (both `None`) only when no
513    /// `system_prompt` was baked at all (pre-existing `system: None`
514    /// semantics, unchanged).
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub system_ref: Option<SystemRef>,
517}
518
519/// GH #31: a by-reference pointer to a baked `system_prompt` too large to
520/// inline into `WorkerPayload.system` — the fetch-time alternative chosen
521/// by `Engine::fetch_worker_payload{,_trusted}` once the rendered string
522/// exceeds `SystemRefConfig.threshold_bytes`. Carries enough to let a
523/// SubAgent (or any caller re-emitting this payload) fetch and verify the
524/// referenced content without re-deriving it from engine state.
525#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
526pub struct SystemRef {
527    /// Scheme-qualified location of the full `system_prompt` body.
528    /// `mode: File` carries a `file://<path>` URI (the exact path
529    /// `SystemRefMode::File` wrote to). `mode: Http` carries only the
530    /// **path** portion the engine can construct on its own
531    /// (`/v1/worker/prompt/system?task_id=<id>&attempt=<n>`) — see
532    /// [`SystemRefMode::Http`]'s doc for why the engine cannot fill in
533    /// scheme/host itself, and who is responsible for doing so.
534    pub uri: String,
535    /// Lowercase hex-encoded SHA-256 digest of the full referenced
536    /// `system_prompt` string (the same bytes `size_bytes` measures),
537    /// letting a fetcher verify the content it retrieves from `uri`
538    /// matches what was baked at dispatch time.
539    pub sha256: String,
540    /// Byte length of the referenced `system_prompt` content itself (not
541    /// of `uri`, not of any wrapper/envelope around it).
542    pub size_bytes: u64,
543    /// Which delivery mechanism `uri` uses — see [`SystemRefMode`].
544    pub mode: SystemRefMode,
545}
546
547/// GH #31: where a [`SystemRef`]'s content is actually served from.
548#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
549#[serde(rename_all = "snake_case")]
550pub enum SystemRefMode {
551    /// Content is served live by the HTTP surface at `SystemRef.uri`,
552    /// backed by the unchanged `EngineState.systems` map — no persisted
553    /// storage is created for this mode. The engine itself only
554    /// constructs the **path** portion of `uri`
555    /// (`/v1/worker/prompt/system?task_id=<id>&attempt=<n>`): it has no
556    /// knowledge of scheme/host at fetch time, so the HTTP-layer caller
557    /// that re-emits this `SystemRef` (the route handler serving
558    /// `worker_prompt`) is responsible for prefixing `scheme://host` if a
559    /// fully-qualified URI is desired.
560    Http,
561    /// Content was written once, as a side effect of the fetch that
562    /// produced this `SystemRef`, to a local file under
563    /// `SystemRefConfig.store_dir`; `SystemRef.uri` is that file's
564    /// `file://<path>` URI. Re-fetching the same `(task_id, attempt)`
565    /// re-writes the same bytes to the same path — harmless in practice
566    /// (SubAgents fetch their prompt once per attempt) but not
567    /// deduplicated.
568    File,
569}
570
571/// Error returned when `CapToken::decode` fails.
572#[derive(Debug, thiserror::Error)]
573pub enum CapTokenDecodeError {
574    /// The input was not valid URL-safe base64.
575    #[error("base64 decode failed: {0}")]
576    Base64(String),
577    /// The decoded bytes were not valid `CapToken` JSON.
578    #[error("json parse failed: {0}")]
579    Json(String),
580}
581
582/// Server-side machinery for minting and verifying tokens.
583#[derive(Debug, Clone)]
584pub struct TokenSigner {
585    secret: Vec<u8>,
586}
587
588impl TokenSigner {
589    /// Build a signer from a raw HMAC secret (any length; HMAC accepts it).
590    pub fn new(secret: impl AsRef<[u8]>) -> Self {
591        Self {
592            secret: secret.as_ref().to_vec(),
593        }
594    }
595
596    /// Mint and sign a [`CapToken`] with an explicit `max_uses` policy.
597    /// Prefer [`Self::one_time`] / [`Self::session`] / [`Self::limited`]
598    /// for the common cases.
599    pub fn mint(
600        &self,
601        agent_id: impl Into<String>,
602        role: Role,
603        scopes: Vec<String>,
604        ttl: Duration,
605        max_uses: Option<u32>,
606    ) -> CapToken {
607        let now = now_unix();
608        let mut token = CapToken {
609            agent_id: agent_id.into(),
610            role,
611            scopes,
612            issued_at: now,
613            expire_at: now + ttl.as_secs(),
614            max_uses,
615            nonce: secure_hex(16),
616            sig_hex: String::new(),
617        };
618        let mut mac =
619            Hmac::<Sha256>::new_from_slice(&self.secret).expect("HMAC accepts any key length");
620        mac.update(&token.signing_input());
621        let sig = mac.finalize().into_bytes();
622        token.sig_hex = hex::encode(sig);
623        token
624    }
625
626    /// HMAC sig verify (constant-time eq for timing side-channel resistance).
627    pub fn verify_sig(&self, token: &CapToken) -> bool {
628        let mut mac =
629            Hmac::<Sha256>::new_from_slice(&self.secret).expect("HMAC accepts any key length");
630        mac.update(&token.signing_input());
631        let expected = mac.finalize().into_bytes();
632        let Ok(provided) = hex::decode(&token.sig_hex) else {
633            return false;
634        };
635        ct_eq(&expected, &provided)
636    }
637
638    /// Builder convenience: one-time token.
639    pub fn one_time(
640        &self,
641        agent_id: impl Into<String>,
642        role: Role,
643        scopes: Vec<String>,
644        ttl: Duration,
645    ) -> CapToken {
646        self.mint(agent_id, role, scopes, ttl, Some(1))
647    }
648
649    /// Builder convenience: session token (unlimited uses until expire).
650    pub fn session(
651        &self,
652        agent_id: impl Into<String>,
653        role: Role,
654        scopes: Vec<String>,
655        ttl: Duration,
656    ) -> CapToken {
657        self.mint(agent_id, role, scopes, ttl, None)
658    }
659
660    /// Builder convenience: limited (N uses).
661    pub fn limited(
662        &self,
663        agent_id: impl Into<String>,
664        role: Role,
665        scopes: Vec<String>,
666        ttl: Duration,
667        max_uses: u32,
668    ) -> CapToken {
669        self.mint(agent_id, role, scopes, ttl, Some(max_uses))
670    }
671}
672
673// ─── helpers ───────────────────────────────────────────────────────────────
674
675pub(crate) fn now_unix() -> u64 {
676    // A clock reporting before the epoch means the host clock is broken in a
677    // way that would otherwise silently mint `issued_at: 0` / `expire_at: 0`
678    // tokens (indistinguishable from "already expired" *and* from "minted at
679    // the epoch") — fail loud instead of laundering that into a bogus
680    // timestamp.
681    SystemTime::now()
682        .duration_since(UNIX_EPOCH)
683        .expect("system clock is before UNIX_EPOCH")
684        .as_secs()
685}
686
687/// In-process-unique, restart-decorrelated hex id.
688///
689/// Combines a monotonic per-process counter (bijective — guarantees no two
690/// calls in the same process ever collide) with a random per-process salt
691/// drawn once from the OS RNG (decorrelates ids across restarts, so a
692/// long-lived id from a previous process run can't be mistaken for one
693/// minted by the current process). The high bits of the 128-bit XOR are
694/// dominated by the salt (a process fingerprint); the low bits change on
695/// every call.
696///
697/// **Not unguessable.** The counter is a public, low-entropy sequence once
698/// the salt leaks (e.g. via any single id from this process) — never use
699/// this for bearer credentials, signing nonces, or anything else that must
700/// resist an adversary who can observe some ids and guess others. Use
701/// [`secure_hex`] for that.
702pub fn uid_hex(bytes: usize) -> String {
703    use std::sync::atomic::{AtomicU64, Ordering};
704    use std::sync::OnceLock;
705    static COUNTER: AtomicU64 = AtomicU64::new(0);
706    static SALT: OnceLock<u128> = OnceLock::new();
707    let salt = *SALT.get_or_init(|| {
708        let mut b = [0u8; 16];
709        getrandom::fill(&mut b).expect("OS RNG unavailable");
710        u128::from_le_bytes(b)
711    });
712    let c = COUNTER.fetch_add(1, Ordering::Relaxed) as u128;
713    // XOR keeps the counter's in-process uniqueness (bijection) while the
714    // per-process random salt decorrelates restarts. High 64 bits are pure
715    // salt (a process fingerprint); low bits change every call.
716    let v = salt ^ c;
717    let raw = format!("{:032x}", v);
718    let n = (bytes * 2).min(32);
719    raw[32 - n..].to_string()
720}
721
722/// OS-RNG hex, safe for bearer credentials.
723///
724/// Every byte comes from the OS random source (`getrandom`) on every call —
725/// unpredictable across calls *and* across process restarts, unlike
726/// [`uid_hex`]. Use this whenever the value itself is the secret: the
727/// [`CapToken`] nonce (its server-side lookup key and part of the signed
728/// material) and worker/session bearer handles.
729pub fn secure_hex(bytes: usize) -> String {
730    let mut buf = vec![0u8; bytes];
731    getrandom::fill(&mut buf).expect("OS RNG unavailable");
732    hex::encode(buf)
733}
734
735/// Hex SHA-256 of a token nonce / bearer string — the lookup-key shape
736/// used by [`CapToken::fingerprint`]. Standalone so callers holding only
737/// the bearer string (not a decoded token) can derive the same key.
738pub fn token_fingerprint(nonce: &str) -> String {
739    use sha2::Digest as _;
740    hex::encode(Sha256::digest(nonce.as_bytes()))
741}
742
743/// Constant-time byte-slice equality (XOR accumulate). Public so bearer
744/// comparisons outside this module (e.g. the operator login token check)
745/// can avoid the timing side channel of `==`.
746pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
747    if a.len() != b.len() {
748        return false;
749    }
750    let mut diff: u8 = 0;
751    for (x, y) in a.iter().zip(b.iter()) {
752        diff |= x ^ y;
753    }
754    diff == 0
755}
756
757#[cfg(test)]
758mod id_newtype_tests {
759    use super::*;
760
761    #[test]
762    fn parse_accepts_prefixed_ids() {
763        assert_eq!(StepId::parse("ST-abc123").unwrap().as_str(), "ST-abc123");
764        assert_eq!(TaskId::parse("T-abc123").unwrap().as_str(), "T-abc123");
765        assert_eq!(RunId::parse("R-abc123").unwrap().as_str(), "R-abc123");
766        assert_eq!(SessionId::parse("S-abc123").unwrap().as_str(), "S-abc123");
767        assert_eq!(WorkerId::parse("W-abc123").unwrap().as_str(), "W-abc123");
768    }
769
770    #[test]
771    fn parse_rejects_wrong_prefix_and_empty_suffix() {
772        // Wrong prefix (including another kind's prefix).
773        assert!(StepId::parse("T-abc").is_err());
774        assert!(TaskId::parse("ST-abc").is_err());
775        assert!(
776            RunId::parse("RK-abc").is_err(),
777            "resume keys are not run ids"
778        );
779        assert!(SessionId::parse("W-abc").is_err());
780        assert!(WorkerId::parse("S-abc").is_err());
781        // Case-sensitive.
782        assert!(StepId::parse("st-abc").is_err());
783        // Prefix alone (nothing after it).
784        assert!(TaskId::parse("T-").is_err());
785        // Garbage / empty.
786        assert!(RunId::parse("nope").is_err());
787        assert!(WorkerId::parse("").is_err());
788    }
789
790    #[test]
791    fn parse_error_carries_kind_prefix_and_input() {
792        let err = TaskId::parse("R-xyz").unwrap_err();
793        assert_eq!(err.kind, "task");
794        assert_eq!(err.expected, "T-");
795        assert_eq!(err.got, "R-xyz");
796        assert_eq!(
797            err.to_string(),
798            "invalid task id `R-xyz`: expected `T-` prefix"
799        );
800    }
801
802    #[test]
803    fn minted_ids_round_trip_through_parse() {
804        assert!(StepId::parse(StepId::new().into_string()).is_ok());
805        assert!(TaskId::parse(TaskId::new().into_string()).is_ok());
806        assert!(RunId::parse(RunId::new().into_string()).is_ok());
807        assert!(SessionId::parse(SessionId::new().into_string()).is_ok());
808        assert!(WorkerId::parse(WorkerId::new().into_string()).is_ok());
809    }
810
811    #[test]
812    fn serde_wire_format_is_a_plain_string() {
813        let id = TaskId::parse("T-abc").unwrap();
814        assert_eq!(
815            serde_json::to_value(&id).unwrap(),
816            serde_json::json!("T-abc")
817        );
818        let back: TaskId = serde_json::from_value(serde_json::json!("T-abc")).unwrap();
819        assert_eq!(back, id);
820    }
821
822    #[test]
823    fn serde_deserialize_validates_prefix() {
824        let err = serde_json::from_value::<TaskId>(serde_json::json!("ST-abc"));
825        assert!(err.is_err(), "deserialize must route through parse");
826    }
827}
828
829#[cfg(test)]
830mod cap_token_fingerprint_tests {
831    use super::*;
832    use std::time::Duration;
833
834    #[test]
835    fn fingerprint_is_sha256_of_nonce_and_not_the_nonce() {
836        let signer = TokenSigner::new("test-secret");
837        let token = signer.session("a", Role::Worker, vec!["*".into()], Duration::from_secs(60));
838        let fp = token.fingerprint();
839        // 32-byte SHA-256, hex-encoded.
840        assert_eq!(fp.len(), 64);
841        // The lookup key must never equal (or contain) the secret nonce.
842        assert_ne!(fp, token.nonce);
843        assert!(!fp.contains(&token.nonce));
844        // Standalone helper derives the same key from the bare bearer string.
845        assert_eq!(fp, token_fingerprint(&token.nonce));
846        // Deterministic per token, distinct across mints.
847        assert_eq!(fp, token.fingerprint());
848        let other = signer.session("a", Role::Worker, vec!["*".into()], Duration::from_secs(60));
849        assert_ne!(fp, other.fingerprint());
850    }
851}
852
853#[cfg(test)]
854mod cap_token_transport_tests {
855    use super::*;
856    use std::time::Duration;
857
858    #[test]
859    fn encode_decode_round_trips() {
860        let signer = TokenSigner::new("test-secret");
861        let token = signer.session(
862            "worker-of-task-x",
863            Role::Worker,
864            vec!["*".into()],
865            Duration::from_secs(600),
866        );
867        let s = token.encode();
868        // URL-safe base64 should not contain `+` `/` `=`
869        assert!(!s.contains('+'));
870        assert!(!s.contains('/'));
871        assert!(!s.contains('='));
872
873        let decoded = CapToken::decode(&s).expect("decode ok");
874        assert_eq!(decoded, token);
875        assert!(
876            signer.verify_sig(&decoded),
877            "HMAC sig still verifies after round-trip"
878        );
879    }
880
881    #[test]
882    fn decode_rejects_garbage() {
883        let err = CapToken::decode("not-base64!!!").expect_err("should fail");
884        assert!(matches!(err, CapTokenDecodeError::Base64(_)));
885    }
886
887    #[test]
888    fn decode_rejects_non_token_json() {
889        use base64::Engine as _;
890        let bogus = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"{\"oops\":1}");
891        let err = CapToken::decode(&bogus).expect_err("should fail json shape");
892        assert!(matches!(err, CapTokenDecodeError::Json(_)));
893    }
894}
895
896// GH #20 Contract C: `WorkerPayload.context` backcompat round-trip.
897#[cfg(test)]
898mod worker_payload_context_tests {
899    use super::*;
900
901    #[test]
902    fn legacy_json_without_context_deserializes_to_none() {
903        // Shape a pre-#20 WorkerPayload would have serialized (no
904        // `context` key at all).
905        let legacy = serde_json::json!({
906            "task_id": "ST-1",
907            "attempt": 1,
908            "agent": "planner",
909            "prompt": "do the thing",
910        });
911        let payload: WorkerPayload =
912            serde_json::from_value(legacy).expect("legacy shape must deserialize");
913        assert!(payload.context.is_none());
914    }
915
916    #[test]
917    fn context_none_serializes_with_key_absent() {
918        let payload = WorkerPayload {
919            task_id: StepId::parse("ST-1").unwrap(),
920            attempt: 1,
921            agent: "planner".to_string(),
922            system: None,
923            prompt: "do the thing".to_string(),
924            context: None,
925            system_ref: None,
926        };
927        let json = serde_json::to_value(&payload).unwrap();
928        assert!(
929            json.as_object().unwrap().get("context").is_none(),
930            "context: None must not appear in the serialized object"
931        );
932    }
933
934    #[test]
935    fn context_some_round_trips() {
936        let view = crate::core::agent_context::AgentContextView::default();
937        let payload = WorkerPayload {
938            task_id: StepId::parse("ST-1").unwrap(),
939            attempt: 1,
940            agent: "planner".to_string(),
941            system: None,
942            prompt: "do the thing".to_string(),
943            context: Some(view.clone()),
944            system_ref: None,
945        };
946        let json = serde_json::to_value(&payload).unwrap();
947        let round_tripped: WorkerPayload = serde_json::from_value(json).unwrap();
948        assert_eq!(round_tripped.context, Some(view));
949    }
950}
951
952// GH #31: `WorkerPayload.system_ref` / `SystemRef` / `SystemRefMode` shape
953// round-trip and backcompat.
954#[cfg(test)]
955mod system_ref_tests {
956    use super::*;
957
958    #[test]
959    fn legacy_json_without_system_ref_deserializes_to_none() {
960        // Shape a pre-#31 WorkerPayload would have serialized (no
961        // `system_ref` key at all).
962        let legacy = serde_json::json!({
963            "task_id": "ST-1",
964            "attempt": 1,
965            "agent": "planner",
966            "prompt": "do the thing",
967        });
968        let payload: WorkerPayload =
969            serde_json::from_value(legacy).expect("legacy shape must deserialize");
970        assert!(payload.system_ref.is_none());
971    }
972
973    #[test]
974    fn system_ref_none_serializes_with_key_absent() {
975        let payload = WorkerPayload {
976            task_id: StepId::parse("ST-1").unwrap(),
977            attempt: 1,
978            agent: "planner".to_string(),
979            system: Some("small prompt".to_string()),
980            prompt: "do the thing".to_string(),
981            context: None,
982            system_ref: None,
983        };
984        let json = serde_json::to_value(&payload).unwrap();
985        assert!(
986            json.as_object().unwrap().get("system_ref").is_none(),
987            "system_ref: None must not appear in the serialized object"
988        );
989    }
990
991    #[test]
992    fn system_ref_some_round_trips_and_excludes_system() {
993        let system_ref = SystemRef {
994            uri: "file:///tmp/mse-system-ref/ST-1-1.md".to_string(),
995            sha256: "a".repeat(64),
996            size_bytes: 30_000,
997            mode: SystemRefMode::File,
998        };
999        let payload = WorkerPayload {
1000            task_id: StepId::parse("ST-1").unwrap(),
1001            attempt: 1,
1002            agent: "planner".to_string(),
1003            system: None,
1004            prompt: "do the thing".to_string(),
1005            context: None,
1006            system_ref: Some(system_ref.clone()),
1007        };
1008        let json = serde_json::to_value(&payload).unwrap();
1009        assert!(
1010            json.as_object().unwrap().get("system").is_none(),
1011            "system: None must not appear in the serialized object"
1012        );
1013        let round_tripped: WorkerPayload = serde_json::from_value(json).unwrap();
1014        assert_eq!(round_tripped.system_ref, Some(system_ref));
1015        assert!(round_tripped.system.is_none());
1016    }
1017
1018    #[test]
1019    fn system_ref_mode_serializes_snake_case() {
1020        assert_eq!(
1021            serde_json::to_value(SystemRefMode::Http).unwrap(),
1022            serde_json::json!("http")
1023        );
1024        assert_eq!(
1025            serde_json::to_value(SystemRefMode::File).unwrap(),
1026            serde_json::json!("file")
1027        );
1028    }
1029}