polyc-controller 2026.8.3

Conversation CRD + kube reconciler for the polychrome control plane.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
//! The `Routine` custom resource (`polychrome.dev/v1alpha1`).
//!
//! A `Routine` is the catalog's **unattended-routine definition noun**: a
//! schedule plus a payload. Publishing one is a cluster apply, so the payload
//! is RBAC-gated by construction — only an operator who can `kubectl apply` a
//! `Routine` can widen what any firing of it may ever do.
//!
//! # The reshape (#1488)
//!
//! This shape supersedes the proof-of-concept's `class`-discriminated spec
//! (`RoutineClass::{Enrolled, FixedContent}`, a bare cron-string `schedule`,
//! and top-level `agentId`/`envelope`/`stepBudget` fields). There is no
//! compat reader and no migration: an old-shape manifest fails to
//! deserialize against this schema and is rejected at admission, per this
//! repo's zero-legacy-code rule. Four pieces replace it:
//!
//! - [`RoutineScope`] — a duplicability class (see the #1802 note below for
//!   its current shape).
//! - [`RoutineSchedule`] — a structured `cron` or `once` firing time, room
//!   for `rrule` later.
//! - [`RoutinePayload`] — the concrete thing a fire action does.
//! - [`RoutineProvenance`] (immutable, API-server-enforced) records who
//!   created a routine; that same persona is its fire principal by rule (see
//!   the #1802 note below — there is no separate run-as field).
//!
//! # The payload becomes a prompt (#1591)
//!
//! Routines are prompt-driven by nature: a routine will fire, open an
//! unattended agent turn, and run its prompt — there is no delivery into an
//! edge and no fixed-content "kind" of routine. [`RoutinePayload`] dissolves
//! from the #1488 reshape's tagged union (fixed content was its sole variant)
//! into a plain STRUCT carrying the prompt text. Future capability — agent
//! binding, tool policy, model parameters — arrives later as FIELDS on this
//! struct, never as a second variant; there is no discriminant left to grow
//! one. The content-template collection (`ContentTemplate`, zero-slot fixed
//! prose rendered platform-side with no model turn) leaves the spec in the
//! same change: no compat reader, no migration, per this repo's
//! zero-legacy-code rule.
//!
//! The prompt fire path landed with #1594:
//! `polyc_control_plane::routine_scheduler` dispatches
//! [`RoutinePayload::prompt`] at fire time as a real, unattended agent turn,
//! through the same `AgentSvc::connect` entry every edge dials, into the
//! firing's own conversation. #1595 had retired the fixed-content fire path
//! (`polyc_control_plane::routine_fire`, deleted) that #1591 left as a
//! documented no-op stub, and #1597 then retired the now-consumerless
//! template machinery outright (the `polyc-template` foundation crate, the
//! turn-runner's synthesized template tool, and the routine-grant wire
//! fields) — there is no template collection anywhere in the workspace
//! anymore, dormant or otherwise.
//!
//! **Still not true today:** the turn a firing opens carries no tools — this
//! struct has no tool-binding or tool-policy field yet, so the prompt can
//! write but not act — and nothing carries its output anywhere. The result is
//! the firing conversation's own history and nothing routes it onward, which
//! is why the docs site marks routines preview.
//!
//! This module defines the schema only — see [`crate::routine_reconcile`]
//! for the pure validator and the reconciler, which reports readiness in
//! `status`. Every routine fires in-process off the control plane's
//! scheduler (`polyc_control_plane::routine_scheduler`, since #1440) rather
//! than a `CronJob`; the reconciler owns no execution primitive of its own,
//! only a one-time migration cleanup of any per-routine `ServiceAccount`/
//! `CronJob` a pre-#1371 reconcile left behind.
//!
//! # Suspend (#1493)
//!
//! [`RoutineSuspend`] (`spec.suspend`) is a second, independently additive
//! spec field layered on top of the #1488 reshape above — deliberately kept
//! out of that breaking change since it needed no wire-shape change to add
//! later. See that type's doc for INV-RL8's suspend/resume contract.
//!
//! # Scope becomes a duplicability class (#1802)
//!
//! [`RoutineScope`] is no longer a reservation for a future output/authority
//! split — the #1488 reshape's `persona`/`shared` reservation story never
//! materialized, and owner-bound routines (#1795) replace it outright.
//! `Instance` is gone too: [`RoutineScope`] now answers exactly one question,
//! "may another member of this instance copy this routine's definition?" —
//! [`RoutineScope::Public`] yes, [`RoutineScope::Private`] (the default) no.
//! Scope never shares a fire, a transcript, or any output; it only ever
//! governs who may view a routine's prompt and schedule and duplicate them
//! into a routine of their own. `RoutineRunAs` is deleted in the same
//! change: a routine's owner (`RoutineProvenance::creator_persona`) is its
//! fire principal by rule, with no field to carry a resolution that can only
//! ever hold one value. Neither half of this is additive — the old scope
//! values are rejected, not migrated, and `run_as` is gone from the wire
//! shape entirely — so it rides the deliberate lockstep-redeploy exception
//! (`docs/reference/upgrade-delivery.md` §8.2) the #1591 payload reshape used:
//! no compat reader, existing CRs hand-migrated to `private` as a deploy
//! step.

use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::condition::Condition;

/// A routine's duplicability class: may another member of this instance
/// view this routine's definition (prompt + schedule) and copy it into a
/// routine of their own?
///
/// This is never an output or authority class — no scope value ever shares a
/// fire, a transcript, or any output, and a routine's owner
/// ([`RoutineProvenance::creator_persona`]) is always its fire principal
/// regardless of scope (#1802). [`Self::Private`] binds MEMBERS, not admins:
/// an admin retains whatever cluster-level access `kubectl` already grants
/// over every `Routine`, private or public. [`Self::Private`] is the
/// default — a routine is visible only to its owner until someone
/// deliberately widens it.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub enum RoutineScope {
    /// Other members of this instance may view this routine's definition and
    /// duplicate it into a routine of their own.
    Public,
    /// Only this routine's owner may view its definition. The default.
    #[default]
    Private,
}

/// A routine's structured firing schedule.
///
/// Supersedes the pre-reshape bare cron-string `schedule` field: a manifest
/// that still sends a plain string for `schedule` fails to deserialize
/// against this (now object-shaped) field and is rejected at admission,
/// never migrated. Externally tagged (`{"cron": {...}}` / `{"once": {...}}`)
/// — the only enum representation Kubernetes' structural-schema validation
/// accepts (an internal `type` tag makes every variant's schema collide on
/// that property when the API server flattens the `oneOf`) — so a third
/// variant (an `rrule` recurrence) can be added later without breaking
/// either existing variant's wire shape.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum RoutineSchedule {
    /// A recurring firing on a standard five-field cron expression (minute
    /// hour day-of-month month day-of-week), optionally in a named IANA time
    /// zone (`None` → UTC). Both fields validate at
    /// [`crate::routine_reconcile::validate_spec`].
    Cron {
        /// The cron expression driving this schedule's ticks.
        expression: String,
        /// IANA time zone the expression is interpreted in (e.g.
        /// `"America/New_York"`). `None` → UTC.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        timezone: Option<String>,
    },
    /// A one-shot firing at exactly one RFC3339 instant. Fires at most once:
    /// the in-process scheduler's `since` cursor never revisits an instant it
    /// already fired, and (INV-RL9, tracked separately) a `once` routine
    /// reflects a terminal completed state after firing rather than ever
    /// showing a next fire again.
    Once {
        /// The RFC3339 instant this routine fires at.
        at: String,
    },
}

/// A routine's payload — the concrete thing a fire action does: the prompt
/// text run at fire time.
///
/// Supersedes the #1488 reshape's tagged union (`{"fixedContent": {...}}`,
/// externally tagged for the same structural-schema reason as
/// [`RoutineSchedule`]) — see the #1591 pivot note in the module doc. There is
/// no variant discriminant left: a routine's fire action always opens an
/// unattended agent turn on [`Self::prompt`]. Future capability — agent
/// binding, tool policy, model parameters — arrives later as additional
/// FIELDS on this struct, never as a second variant.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct RoutinePayload {
    /// The prompt text run at fire time — the entire content of the
    /// unattended agent turn the routine's schedule opens. Must be
    /// non-empty — a routine with nothing to say has no reason to run
    /// unattended (see [`crate::routine_reconcile::validate_spec`]).
    pub prompt: String,
}

/// Immutable record of who created a routine and from where.
///
/// Stamped once at admission and never rewritten by any executor verb
/// (INV-RL7) — enforced at the API server by a CEL transition rule on the
/// `Routine` CRD (`self.spec.provenance == oldSelf.spec.provenance`, see the
/// `validation` attribute on [`Routine`]'s derive), not only by convention.
/// Kubernetes only evaluates an `oldSelf`-referencing rule on an UPDATE, so a
/// fresh `create` is unaffected; every later write to the same object must
/// carry this back unchanged or the API server rejects it before it's
/// persisted.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct RoutineProvenance {
    /// The persona id of whoever asked the agent to create this routine.
    pub creator_persona: String,
    /// The id of the conversation the create request was compiled from.
    pub conversation_id: String,
}

/// Human-declared pause intent for a routine, and its who/when/why audit
/// metadata.
///
/// [`RoutineSpec::suspend`]'s PRESENCE is INV-RL8's entire suspend signal —
/// there is no separate boolean to drift out of sync with it: a suspended
/// routine never fires and never raises `MissedFire`; on resume (clearing
/// this field), the first fire is the next scheduled tick, never a backfill
/// of ticks that fell inside the pause, however short (the scheduler's tick
/// loop enforces this as an explicit branch, never as an emergent property of
/// its 5-minute catch-up window). No system process ever clears this field —
/// only a human resume does; a future system-judged quarantine would need its
/// own, separate field rather than reusing this one.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct RoutineSuspend {
    /// Who paused this routine — a persona id once the chat pause verb
    /// (#1495) exists; until then, whatever the operator names when
    /// patching the CR directly.
    pub paused_by: String,
    /// RFC3339 instant the pause was recorded.
    pub paused_at: String,
    /// Why the routine was paused, if the pauser gave one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
}

/// Desired state of one published routine definition: a schedule plus a
/// payload.
///
/// [`Self::payload`] bounds what the routine's fire action does — see the
/// module docs for how this shape supersedes the pre-reshape `class`-split
/// spec, and the #1591 pivot for how the payload itself dissolved from a
/// tagged union into a prompt struct.
#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[kube(
    group = "polychrome.dev",
    version = "v1alpha1",
    kind = "Routine",
    namespaced,
    status = "RoutineStatus",
    shortname = "rtn",
    category = "polychrome",
    derive = "PartialEq",
    validation = Rule::new("self.spec.provenance == oldSelf.spec.provenance").message(
        "A routine's provenance records who created it and from which conversation. It's set once \
         when the routine is created and can't be changed afterward."
    ),
    printcolumn = r#"{"name":"Ready","type":"boolean","jsonPath":".status.ready"}"#,
    printcolumn = r#"{"name":"Scope","type":"string","jsonPath":".spec.scope"}"#,
    printcolumn = r#"{"name":"LastFire","type":"date","jsonPath":".status.lastFireTime"}"#,
    printcolumn = r#"{"name":"NextFire","type":"date","jsonPath":".status.nextFireTime"}"#,
    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
)]
#[serde(rename_all = "camelCase")]
pub struct RoutineSpec {
    /// Human-readable description of what this routine does.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Whether other members of this instance may view this routine's
    /// definition and duplicate it — never who it runs as or what it may
    /// touch. See [`RoutineScope`].
    #[serde(default)]
    pub scope: RoutineScope,
    /// The structured firing schedule. See [`RoutineSchedule`].
    pub schedule: RoutineSchedule,
    /// The tagged payload — the concrete thing a fire action does. See
    /// [`RoutinePayload`].
    pub payload: RoutinePayload,
    /// Immutable creator persona and originating conversation id. See
    /// [`RoutineProvenance`]. This is also the fire principal: a routine
    /// always runs as its owner, by rule (#1802) — there is no separate
    /// run-as field to drift from it.
    pub provenance: RoutineProvenance,
    /// Human-declared pause intent, additive to the #1488 reshape (#1493).
    /// `None` while the routine is active. See [`RoutineSuspend`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub suspend: Option<RoutineSuspend>,
}

/// Observed state, written back to the `status` subresource by the controller.
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct RoutineStatus {
    /// `true` when the spec validated and the routine is ready to be
    /// resolved (see `crate::routine_reconcile`).
    #[serde(default)]
    pub ready: bool,
    /// Lifecycle phase: `"Ready"` once the spec validates, `"Degraded"` when
    /// it does not.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub phase: Option<String>,
    /// Human-readable status detail (e.g. the validation error that put the
    /// routine into `Degraded`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
    /// RFC3339 timestamp of the last tick the in-process routine scheduler
    /// (#1370) actually fired. `None` until the first fire.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_fire_time: Option<String>,
    /// RFC3339 timestamp of the next tick the scheduler's pure tick math
    /// (`polyc-control-plane::routine_scheduler::tick`) computes from the
    /// current time. `None` when the routine isn't (or isn't yet)
    /// scheduler-managed, its cron expression can never match any calendar
    /// date, or (for a `once` schedule) it has already fired.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub next_fire_time: Option<String>,
    /// Standard `status.conditions`, wire-compatible with `metav1.Condition`
    /// (mirrors `ConversationStatus::conditions`; see
    /// [`crate::condition::upsert_condition`]). Written only by the scheduler
    /// (a disjoint field-manager patch from [`crate::routine_reconcile`]'s
    /// `ready`/`phase`/`message` sync — see that module's docs):
    ///
    /// - `MissedFire`: `True` (naming the skipped occurrence(s)) the instant
    ///   a due tick falls outside the catch-up window, `False` again once
    ///   the routine next fires successfully. Never raised while
    ///   [`RoutineSpec::suspend`] is set (INV-RL8).
    /// - `Suspended`: mirrors [`RoutineSpec::suspend`] every pass — `True`
    ///   with its who/when/why in the message while paused, `False` once
    ///   resumed.
    /// - `Completed`: `True`, naming the fire time, once a `once` schedule
    ///   has fired (INV-RL9's terminal state) — never set for a `cron`
    ///   schedule.
    /// - `AwaitingSetup`: `True` until this routine's owner
    ///   finishes its attended setup rehearsal (no `routine_setup_completed`
    ///   marker yet), `False` once it does. No due tick dispatches while
    ///   `True`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub conditions: Vec<Condition>,
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]

    use super::*;
    use kube::CustomResourceExt;
    use serde_json::{Value, json};

    fn provenance() -> RoutineProvenance {
        RoutineProvenance {
            creator_persona: "persona-1".to_owned(),
            conversation_id: "conv-1".to_owned(),
        }
    }

    #[test]
    fn crd_identity_is_polychrome_routine() {
        let crd = Routine::crd();
        assert_eq!(crd.spec.group, "polychrome.dev");
        assert_eq!(crd.spec.names.kind, "Routine");
        assert_eq!(crd.spec.names.plural, "routines");
    }

    /// Acceptance criterion 4: provenance immutability is a real API-server
    /// CEL rule, not only convention — it must show up in the generated CRD's
    /// `x-kubernetes-validations`, referencing the transition (`oldSelf`)
    /// shape that only fires on update.
    #[test]
    fn crd_carries_a_provenance_immutability_cel_rule() {
        let crd = Routine::crd();
        let json = serde_json::to_value(&crd).expect("crd serializes");
        let rules =
            json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["x-kubernetes-validations"]
                .clone();
        let rules = rules.as_array().expect("at least one CEL rule");
        assert!(
            rules.iter().any(|r| {
                r["rule"]
                    .as_str()
                    .is_some_and(|rule| rule.contains("provenance") && rule.contains("oldSelf"))
            }),
            "expected a provenance immutability rule referencing oldSelf, got: {rules:?}"
        );
    }

    /// #1802: `run_as` is gone from the wire shape entirely — a routine's
    /// owner is its fire principal by rule, with no separate field to carry
    /// a resolution. The generated CRD schema must carry no `runAs`
    /// property at all, not merely an unenforced one.
    #[test]
    fn crd_schema_carries_no_run_as_property() {
        let crd = Routine::crd();
        let json = serde_json::to_value(&crd).expect("crd serializes");
        let properties = &json["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]
            ["properties"];
        assert!(
            properties.get("runAs").is_none(),
            "runAs must not appear in the CRD schema: {properties}"
        );
    }

    #[test]
    fn full_routine_yaml_round_trips() {
        let yaml = json!({
            "description": "post a daily standup summary",
            "scope": "private",
            "schedule": { "cron": { "expression": "0 9 * * 1-5" } },
            "payload": { "prompt": "Post a short standup summary to #standup." },
            "provenance": { "creatorPersona": "persona-1", "conversationId": "conv-1" },
        });
        let spec: RoutineSpec = serde_json::from_value(yaml).expect("full spec deserializes");
        assert_eq!(spec.scope, RoutineScope::Private);
        assert_eq!(
            spec.payload.prompt,
            "Post a short standup summary to #standup."
        );
        assert_eq!(spec.suspend, None, "omitted suspend defaults absent");
        let v: Value = serde_json::to_value(&spec).unwrap();
        assert!(v.get("runAs").is_none(), "runAs must never serialize: {v}");
    }

    /// #1493: `suspend` is additive — a manifest written before the field
    /// existed (no `suspend` key at all, exactly [`full_routine_yaml_round_trips`]'s
    /// fixture) still deserializes, with `suspend` absent.
    #[test]
    fn suspend_is_additive_and_absent_by_default() {
        let spec = spec_with("Post a short standup summary to #standup.");
        assert_eq!(spec.suspend, None);
        let v = serde_json::to_value(&spec).unwrap();
        assert!(
            v.get("suspend").is_none(),
            "an unset suspend must not even serialize the key: {v}"
        );
    }

    /// #1493 acceptance criterion 3: `spec.suspend`'s who/when/why round-trips
    /// through the wire shape unchanged, camelCase field names included.
    #[test]
    fn suspend_with_a_reason_round_trips_camel_case() {
        let mut spec = spec_with("Post a short standup summary to #standup.");
        spec.suspend = Some(RoutineSuspend {
            paused_by: "persona-1".to_owned(),
            paused_at: "2026-07-23T00:00:00Z".to_owned(),
            reason: Some("rotating out old announcements".to_owned()),
        });
        let v = serde_json::to_value(&spec).unwrap();
        assert_eq!(v["suspend"]["pausedBy"], "persona-1");
        assert_eq!(v["suspend"]["pausedAt"], "2026-07-23T00:00:00Z");
        assert_eq!(v["suspend"]["reason"], "rotating out old announcements");
        assert_eq!(serde_json::from_value::<RoutineSpec>(v).unwrap(), spec);
    }

    /// `reason` is optional even while suspended — a pause needs no
    /// justification to be valid.
    #[test]
    fn suspend_without_a_reason_round_trips_and_omits_the_key() {
        let mut spec = spec_with("Post a short standup summary to #standup.");
        spec.suspend = Some(RoutineSuspend {
            paused_by: "persona-1".to_owned(),
            paused_at: "2026-07-23T00:00:00Z".to_owned(),
            reason: None,
        });
        let v = serde_json::to_value(&spec).unwrap();
        assert!(
            v["suspend"].get("reason").is_none(),
            "an absent reason must not serialize the key: {v}"
        );
        assert_eq!(serde_json::from_value::<RoutineSpec>(v).unwrap(), spec);
    }

    /// Acceptance criterion 2 (bare-string half): the pre-reshape wire shape
    /// — `schedule` as a plain cron string — fails to deserialize against the
    /// now object-shaped field. Rejected, not migrated.
    #[test]
    fn bare_string_schedule_is_rejected_not_migrated() {
        let yaml = json!({
            "scope": "private",
            "schedule": "0 9 * * 1-5",
            "payload": { "prompt": "Post a short standup summary to #standup." },
            "provenance": { "creatorPersona": "persona-1", "conversationId": "conv-1" },
        });
        assert!(
            serde_json::from_value::<RoutineSpec>(yaml).is_err(),
            "a bare-string schedule must fail to deserialize, not silently migrate"
        );
    }

    /// Acceptance criterion 2 (both variants): `cron` and `once` both
    /// deserialize.
    #[test]
    fn schedule_cron_and_once_both_deserialize() {
        let cron: RoutineSchedule =
            serde_json::from_value(json!({ "cron": { "expression": "0 9 * * 1-5" } }))
                .expect("cron variant deserializes");
        assert_eq!(
            cron,
            RoutineSchedule::Cron {
                expression: "0 9 * * 1-5".to_owned(),
                timezone: None,
            }
        );
        let once: RoutineSchedule =
            serde_json::from_value(json!({ "once": { "at": "2026-08-01T15:00:00Z" } }))
                .expect("once variant deserializes");
        assert_eq!(
            once,
            RoutineSchedule::Once {
                at: "2026-08-01T15:00:00Z".to_owned(),
            }
        );
    }

    /// #1802 acceptance criterion 1: `scope` accepts exactly `public` and
    /// `private`, with `private` the default.
    #[test]
    fn scope_accepts_exactly_public_and_private() {
        for (raw, expected) in [
            ("public", RoutineScope::Public),
            ("private", RoutineScope::Private),
        ] {
            let scope: RoutineScope =
                serde_json::from_value(json!(raw)).unwrap_or_else(|_| panic!("{raw} parses"));
            assert_eq!(scope, expected);
        }
        assert_eq!(RoutineScope::default(), RoutineScope::Private);
    }

    /// #1802 acceptance criterion 2: the retired reserved values
    /// (`instance`/`persona`/`shared`) are rejected — not migrated — at
    /// deserialize time, since the wire enum no longer names them.
    #[test]
    fn scope_rejects_the_retired_reserved_values() {
        for raw in ["instance", "persona", "shared"] {
            assert!(
                serde_json::from_value::<RoutineScope>(json!(raw)).is_err(),
                "{raw} must be rejected, not migrated"
            );
        }
    }

    #[test]
    fn spec_round_trips_camel_case() {
        let spec = RoutineSpec {
            description: Some("standup".to_owned()),
            scope: RoutineScope::Private,
            schedule: RoutineSchedule::Cron {
                expression: "0 9 * * 1-5".to_owned(),
                timezone: Some("America/New_York".to_owned()),
            },
            payload: RoutinePayload {
                prompt: "Post a short standup summary to #standup.".to_owned(),
            },
            provenance: provenance(),
            suspend: None,
        };
        let v = serde_json::to_value(&spec).unwrap();
        assert!(v["schedule"]["cron"].is_object(), "{v}");
        assert_eq!(v["schedule"]["cron"]["timezone"], "America/New_York");
        assert_eq!(
            v["payload"]["prompt"],
            Value::from("Post a short standup summary to #standup."),
            "{v}"
        );
        assert_eq!(v["provenance"]["creatorPersona"], Value::from("persona-1"));
        assert!(v.get("runAs").is_none(), "runAs must never serialize: {v}");
        assert_eq!(serde_json::from_value::<RoutineSpec>(v).unwrap(), spec);
    }

    /// The class discriminant (acceptance criterion 3): the old top-level
    /// `class` key is gone. A manifest still shaped like the pre-reshape spec
    /// (`class` + a bare-string `schedule` + no `payload`/`provenance`) fails
    /// to deserialize.
    #[test]
    fn old_shape_manifest_with_class_field_is_rejected() {
        let yaml = json!({
            "class": "fixedContent",
            "agentId": null,
            "schedule": "0 9 * * *",
            "contentTemplates": [],
        });
        assert!(
            serde_json::from_value::<RoutineSpec>(yaml).is_err(),
            "old-shape `class`-discriminated manifest must fail admission, not migrate"
        );
    }

    /// #1591: the payload's tagged union (`{"fixedContent": {...}}`) is gone
    /// — the pre-#1591 shape fails to deserialize against the now-bare
    /// `{"prompt": "..."}` struct.
    #[test]
    fn old_fixed_content_payload_shape_is_rejected_not_migrated() {
        let yaml = json!({
            "scope": "private",
            "schedule": { "cron": { "expression": "0 9 * * *" } },
            "payload": {
                "fixedContent": {
                    "contentTemplates": [{
                        "name": "standup_summary_v1",
                        "destination": { "provider": "slack", "channel": "C0STANDUP" },
                        "slots": [],
                        "prose": "{title_line}...",
                    }],
                },
            },
            "provenance": { "creatorPersona": "persona-1", "conversationId": "conv-1" },
        });
        assert!(
            serde_json::from_value::<RoutineSpec>(yaml).is_err(),
            "the pre-#1591 tagged-union payload shape must fail to deserialize, not migrate"
        );
    }

    fn spec_with(prompt: &str) -> RoutineSpec {
        RoutineSpec {
            description: None,
            scope: RoutineScope::Private,
            schedule: RoutineSchedule::Cron {
                expression: "0 9 * * 1-5".to_owned(),
                timezone: None,
            },
            payload: RoutinePayload {
                prompt: prompt.to_owned(),
            },
            provenance: provenance(),
            suspend: None,
        }
    }
}