Skip to main content

kanade_shared/
kv.rs

1//! NATS KV bucket name + key helpers (spec §2.3.2).
2//!
3//! NATS KV bucket names must be domain-safe ASCII (a-z, A-Z, 0-9, _, -),
4//! so the spec's dotted names (`script.current`, `script.status`) are
5//! flattened to underscore form here.
6
7pub const BUCKET_SCRIPT_CURRENT: &str = "script_current";
8pub const BUCKET_SCRIPT_STATUS: &str = "script_status";
9pub const BUCKET_AGENTS_STATE: &str = "agents_state";
10pub const BUCKET_AGENT_CONFIG: &str = "agent_config";
11pub const BUCKET_AGENT_GROUPS: &str = "agent_groups";
12
13/// `agent_meta` — per-PC operator-managed free-form key/value
14/// annotations (the primary user's name / email / department, an ad-hoc
15/// note), keyed by `pc_id`, value JSON
16/// [`AgentMeta`](crate::wire::AgentMeta). Durable operator metadata —
17/// distinct from the volatile `agents` heartbeat projection and from
18/// `agent_groups` membership. Edited via the SPA agent detail page or the
19/// `kanade meta` CLI (and typically bulk-populated by an operator AD-sync
20/// job that resolves the last-logon user's directory attributes).
21pub const BUCKET_AGENT_META: &str = "agent_meta";
22
23/// `group_contacts` — per-group notification email addresses, keyed by
24/// group name, value JSON [`GroupContacts`](crate::wire::GroupContacts).
25/// Operator-managed via the SPA Groups page. Distinct from
26/// `agent_groups` (per-PC membership) and `agent_config`'s `groups.*`
27/// scopes (agent config pushed to machines): this is operator contact
28/// info, read backend-side to fan a compliance alert out to email.
29pub const BUCKET_GROUP_CONTACTS: &str = "group_contacts";
30
31pub const BUCKET_SCHEDULES: &str = "schedules";
32
33/// Job catalog (v0.15) — operator-registered Manifests, keyed by
34/// `manifest.id`. Schedules and ad-hoc `kanade run --job-id ...` look
35/// jobs up here; the wire never round-trips an inline Manifest body
36/// through a Schedule again. Editing a job in-place retroactively
37/// changes what future schedule fires deploy.
38pub const BUCKET_JOBS: &str = "jobs";
39
40/// Parallel "operator source-of-truth YAML" stores keyed identically
41/// to `BUCKET_JOBS` / `BUCKET_SCHEDULES`. The agent / scheduler /
42/// projector all keep reading the JSON KVs above — these buckets
43/// exist only so the SPA's YAML editor can round-trip operator
44/// comments + script indentation + block-scalar style exactly.
45///
46/// Population is opportunistic: any `POST` with a
47/// `Content-Type: application/yaml` body stores the raw bytes here
48/// alongside the parsed JSON; JSON-content-type POSTs fall back to a
49/// `serde_yaml` dump so the buckets stay in lockstep with the JSON
50/// store (operator just loses comments on that path).
51pub const BUCKET_JOBS_YAML: &str = "jobs_yaml";
52pub const BUCKET_SCHEDULES_YAML: &str = "schedules_yaml";
53
54/// View catalog (#743) — operator-registered [`View`](crate::manifest::View)
55/// resources, keyed by `view.id`. A view is a pure, declarative
56/// read/aggregation over stored fleet data (`obs_events`, …) for the
57/// Analytics page — no `execute`, no schedule. The backend reads these at
58/// query time and merges their widgets with the co-located `aggregate:`
59/// hints on jobs. Distinct from `BUCKET_JOBS` so a cross-cutting dashboard
60/// doesn't need a noop job carrier.
61pub const BUCKET_VIEWS: &str = "views";
62/// Operator source-of-truth YAML mirror for `BUCKET_VIEWS` (same role as
63/// `BUCKET_JOBS_YAML`): keeps comments/formatting for the SPA editor.
64pub const BUCKET_VIEWS_YAML: &str = "views_yaml";
65
66/// Fleet-wide singleton settings that aren't per-agent (so they don't
67/// belong in `agent_config`'s layered scopes) and aren't per-schedule
68/// (so they don't belong in `schedules`). First and only key so far is
69/// [`KEY_FREEZE`] (#418 Phase 5 global change-freeze). One small bucket
70/// both the backend scheduler and every agent's local scheduler watch.
71pub const BUCKET_FLEET_CONFIG: &str = "fleet_config";
72
73/// Backend-side, operator-editable server settings that aren't per-agent
74/// (so they don't belong in `agent_config`'s layered scopes) and aren't a
75/// fleet-wide switch every agent watches (so they don't belong in
76/// `fleet_config`). A single JSON document under [`KEY_SERVER_SETTINGS`]
77/// holding [`crate::wire::ServerSettings`], managed via the SPA Settings
78/// page's "server settings" tab. Deliberately generic: future server-side
79/// knobs join the same document rather than spawning a bucket each. First
80/// consumer is the cleanup task's dead-agent prune window
81/// (`ServerSettings::agent_prune_days`). `history: 1` — only the current
82/// state matters; nothing replays its history.
83pub const BUCKET_SERVER_SETTINGS: &str = "server_settings";
84
85/// Singleton key in [`BUCKET_SERVER_SETTINGS`] holding the JSON-encoded
86/// [`crate::wire::ServerSettings`]. **Key absent ⇒ all-default settings**
87/// (e.g. `agent_prune_days = 0`, pruning disabled), so a fresh deployment
88/// behaves exactly as it did before the bucket existed.
89pub const KEY_SERVER_SETTINGS: &str = "current";
90
91/// `notifications_read` — per-user read/ack state for end-user
92/// notifications (SPEC §2.3.2 / Phase E). Key shape
93/// `{pc_id}.{user_sid}.{notification_id}`, value JSON
94/// `{"acked_at": ..., "acked_by": "<sid>"}`. The agent writes a row
95/// when it handles a KLP `notifications.ack`, stamping the connecting
96/// user's OS-derived SID — so a shared PC tracks each user's reads
97/// independently. The `{pc_id}.{user_sid}.` prefix lets
98/// `notifications.list` fetch one user's read set with a single prefix
99/// walk. `history: 1` — only the latest ack per key matters.
100pub const BUCKET_NOTIFICATIONS_READ: &str = "notifications_read";
101
102/// KV key in [`BUCKET_NOTIFICATIONS_READ`] for one user's ack of one
103/// notification: `{pc_id}.{user_sid}.{notification_id}` (SPEC §2.3.2).
104///
105/// The components are joined with `.` per the spec's documented key
106/// shape. In practice none of them contain a `.` — `pc_id` is a
107/// hostname, `user_sid` is `S-1-5-…` (hyphen-delimited), and the
108/// backend mints `notification_id` as a UUID (operator-supplied
109/// manifest ids are kebab-case) — so the join stays unambiguous and
110/// the `{pc_id}.{user_sid}.` prefix (see
111/// [`notifications_read_prefix`]) cleanly selects exactly one user's
112/// read set for `notifications.list`.
113pub fn notifications_read_key(pc_id: &str, user_sid: &str, notification_id: &str) -> String {
114    format!("{pc_id}.{user_sid}.{notification_id}")
115}
116
117/// Prefix selecting every ack row for one `(pc_id, user_sid)` in
118/// [`BUCKET_NOTIFICATIONS_READ`] — `{pc_id}.{user_sid}.`.
119/// `notifications.list` walks the bucket keys and keeps those carrying
120/// this prefix to compute the caller's unread set. Pairs with
121/// [`notifications_read_key`].
122pub fn notifications_read_prefix(pc_id: &str, user_sid: &str) -> String {
123    format!("{pc_id}.{user_sid}.")
124}
125
126/// Singleton key in [`BUCKET_FLEET_CONFIG`] holding the JSON-encoded
127/// [`crate::manifest::Freeze`]. **Key absent ⇒ not frozen** (clearing
128/// the freeze is a KV delete), so readers treat a missing key as "fire
129/// normally" and only evaluate `Freeze::is_active` when the key exists.
130pub const KEY_FREEZE: &str = "freeze";
131
132/// KV bucket holding **per-(schedule, pc) last-dispatch marks** for the
133/// backend scheduler's in-flight suppression.
134///
135/// The per-pc / per-target dedup ([`crate::manifest::ExecMode`]) only
136/// sees *completed* runs (`execution_results`, exit_code = 0). Since
137/// #418 the reconcile poll runs every minute ([`crate::manifest::POLL_CRON`]),
138/// but a dispatched Command doesn't land a completion until
139/// `jitter (agent-side) + run + outbox drain` later — frequently
140/// several minutes with a 3–5 min jitter. Without a dispatch record the
141/// poll re-fires the same PC (or whole target) every tick across that
142/// gap. This bucket records "I dispatched (schedule, pc) at T" so the
143/// scheduler can suppress re-fire for a bounded window without waiting
144/// on the completion round-trip.
145///
146/// Values are the dispatch instant as an RFC3339 string. A bucket-wide
147/// `max_age` GCs marks once they're well past any suppression window,
148/// so the bucket can't grow unbounded; the suppression-window check
149/// itself lives in `scheduler::policy::suppress_dispatched`.
150pub const BUCKET_SCHEDULER_DISPATCH: &str = "scheduler_dispatch";
151
152/// Per-pc dispatch-mark key (OncePerPc).
153///
154/// The `pc.` / `target.` kind prefix keeps the two namespaces apart,
155/// and each component is **length-prefixed** (`<len>.<value>`) so no two
156/// distinct `(schedule_id, pc_id)` pairs can ever collide — even when an
157/// id contains the `.` separator: `("a.b", "c")` → `pc.3.a.b.1.c`,
158/// `("a", "b.c")` → `pc.1.a.3.b.c`. (Percent-/base64-encoding isn't an
159/// option: NATS KV keys only allow `[-/_=.a-zA-Z0-9]`, so the
160/// self-delimiting length prefix is the cheapest injective encoding that
161/// stays in-charset.)
162pub fn dispatch_mark_pc_key(schedule_id: &str, pc_id: &str) -> String {
163    format!(
164        "pc.{}.{}.{}.{}",
165        schedule_id.len(),
166        schedule_id,
167        pc_id.len(),
168        pc_id
169    )
170}
171
172/// Whole-target dispatch-mark key (OncePerTarget). One key per
173/// schedule — a per-target fire dispatches the whole target at once, so
174/// there's nothing per-pc to record. Length-prefixed for symmetry with
175/// [`dispatch_mark_pc_key`].
176pub fn dispatch_mark_target_key(schedule_id: &str) -> String {
177    format!("target.{}.{}", schedule_id.len(), schedule_id)
178}
179
180/// Object Store bucket holding raw agent binaries (one object per
181/// version, e.g. `0.2.0` → file bytes).
182pub const OBJECT_AGENT_RELEASES: &str = "agent_releases";
183
184/// Object Store holding **generic application packages** — anything
185/// the agent / kitting scripts pull down + install on endpoints.
186/// First consumer is the kanade-client app, but the bucket is
187/// intentionally generic: third-party installers (Webex, Teams,
188/// custom MSI bundles), upgrade scripts, configuration archives,
189/// etc. all live here.
190///
191/// Object keys are `<name>/<version>` — operator picks `<name>`
192/// once per package family (e.g. `kanade-client`,
193/// `webex-meetings`), then `<version>` per release (e.g.
194/// `0.41.0`, `2025.03`). Slashes are explicitly allowed by NATS
195/// Object Store key rules; the SPA / CLI / HTTP routes all carry
196/// the pair as two path segments.
197///
198/// Why a separate bucket from `agent_releases`:
199/// - `agent_releases` is fleet-critical (the agent's own self-
200///   update path). Keeping it small + audited matters.
201/// - `app_packages` is operator-curated user-space content. The
202///   lifecycle is different (operators add/remove packages
203///   freely; agent releases follow the release.yml pipeline).
204pub const OBJECT_APP_PACKAGES: &str = "app_packages";
205
206/// Object Store holding **manifest script bodies** referenced by
207/// `Execute::script_object` (SPEC §2.4.1's alternative to inline
208/// `script:` / repo-local `script_file:`). Per yukimemi/kanade
209/// issue #210, this is the "Plan B 4-bucket layout" sibling of
210/// `app_packages` — separated because scripts have a different
211/// lifecycle than installer binaries:
212///
213/// - Smaller (typical KB-to-low-MB, vs MB-to-hundreds-of-MB
214///   installers).
215/// - Coupled to manifest versions (script lifecycle = manifest
216///   lifecycle; the `script_current` / `script_status` KV gates
217///   in SPEC §2.6.2 already track manifest versions, so a
218///   matching dedicated bucket keeps the audit story aligned).
219/// - Different access pattern (every Command execute potentially
220///   fetches; vs installer fetched once per fleet deploy).
221///
222/// Object keys follow the same `<name>/<version>` shape as
223/// `app_packages` so the SPA / operator tooling stays uniform.
224/// For manifest-driven scripts `<name>` is the manifest id and
225/// `<version>` is the manifest version, but the bucket itself
226/// imposes no semantics on the pair — operator-uploaded
227/// ad-hoc scripts can use any `<name>/<version>` they like.
228pub const OBJECT_SCRIPTS: &str = "scripts";
229
230/// Object Store holding **overflow stdout / stderr blobs** for the
231/// `ExecResult` wire kind (#227). The default NATS `max_payload` is
232/// 1 MB; a result whose stdout / stderr exceeds it would reject the
233/// publish and pin the agent's outbox in a reconnect loop. The agent
234/// uploads any stdout / stderr larger than `STDOUT_INLINE_THRESHOLD`
235/// (256 KB, picked at 1/4 of the default max_payload so the rest of
236/// the ExecResult fields fit alongside) into this bucket and replaces
237/// the inline field with [`crate::wire::ExecResult::stdout_object`] /
238/// `stderr_object` pointers. Backend's results projector derefs the
239/// pointers before INSERT so downstream consumers (SQLite, SPA
240/// Activity, inventory projector) see the full text the same way
241/// they always have.
242///
243/// Object keys follow the shape `<request_id>/{stdout,stderr}` so
244/// stdout + stderr for the same execution share a sibling prefix —
245/// makes `kanade jetstream` listings group naturally and keeps the
246/// per-key namespace tight against duplicate uploads.
247///
248/// Per-bucket retention (not a stream-wide TTL since async-nats
249/// object_store inherits stream config): matches `STREAM_RESULTS`'s
250/// 30-day retention so an operator who can still query the result
251/// row in SQLite can also fetch the original blob if the inline
252/// copy ever needs re-projection.
253pub const OBJECT_RESULT_OUTPUT: &str = "result_output";
254
255/// Object Store holding **collected file bundles** (#219). A job
256/// carrying a `collect:` manifest hint prints a JSON list of file
257/// paths on stdout; the agent zips them and uploads the archive here,
258/// recording the key in [`crate::wire::ExecResult::collect_object`].
259/// The SPA Collect page lists / downloads bundles straight from this
260/// bucket. Object keys follow `<pc_id>/<job_id>/<rfc3339>.zip`, or
261/// `<pc_id>/<job_id>/<label>__<rfc3339>.zip` when a run emits multiple
262/// labeled bundles (e.g. one zip per day), so a listing groups by host
263/// then job. Per-bucket retention is 30 days
264/// (bundles are debugging/audit artifacts, not curated config like
265/// `app_packages` / `scripts`, so they auto-expire) — see
266/// `kanade-shared::bootstrap`.
267pub const OBJECT_COLLECTIONS: &str = "collections";
268
269/// Inline threshold for `ExecResult.stdout` / `.stderr`. Larger
270/// payloads overflow into [`OBJECT_RESULT_OUTPUT`]. 256 KB = 1/4 of
271/// the NATS default `max_payload` (1 MB) so the rest of the
272/// ExecResult JSON (request_id, exec_id, etc.) easily fits below the
273/// publish-reject ceiling.
274///
275/// Lives next to the bucket constant rather than on the agent side
276/// so the SPA / future operator tooling can quote the same threshold
277/// when explaining "why this result has no inline stdout".
278pub const STDOUT_INLINE_THRESHOLD: usize = 256 * 1024;
279
280/// Key inside [`BUCKET_AGENT_CONFIG`] carrying the broadcast target
281/// version. Agents watch this key and self-update when their running
282/// version drifts.
283pub const KEY_AGENT_TARGET_VERSION: &str = "target_version";
284
285/// Sprint 6 layered-config keys inside [`BUCKET_AGENT_CONFIG`]:
286///   * `global`        — fleet-wide default ConfigScope JSON
287///   * `groups.<name>` — per-group override (partial ConfigScope)
288///   * `pcs.<pc_id>`   — per-pc override (partial ConfigScope)
289///
290/// The `groups.` / `pcs.` prefixes let a `kv.keys()` walk pick out
291/// just the rows in one scope when listing.
292pub const KEY_AGENT_CONFIG_GLOBAL: &str = "global";
293pub const PREFIX_AGENT_CONFIG_GROUPS: &str = "groups.";
294pub const PREFIX_AGENT_CONFIG_PCS: &str = "pcs.";
295
296pub fn agent_config_group_key(group: &str) -> String {
297    format!("{PREFIX_AGENT_CONFIG_GROUPS}{group}")
298}
299
300pub fn agent_config_pc_key(pc_id: &str) -> String {
301    format!("{PREFIX_AGENT_CONFIG_PCS}{pc_id}")
302}
303
304/// Inverse of [`agent_config_group_key`] — returns the bare group
305/// name if `key` carries the groups-scope prefix, else `None`.
306pub fn parse_agent_config_group_key(key: &str) -> Option<&str> {
307    key.strip_prefix(PREFIX_AGENT_CONFIG_GROUPS)
308}
309
310/// Inverse of [`agent_config_pc_key`].
311pub fn parse_agent_config_pc_key(key: &str) -> Option<&str> {
312    key.strip_prefix(PREFIX_AGENT_CONFIG_PCS)
313}
314
315pub const SCRIPT_STATUS_ACTIVE: &str = "ACTIVE";
316pub const SCRIPT_STATUS_REVOKED: &str = "REVOKED";
317
318pub const STREAM_INVENTORY: &str = "INVENTORY";
319pub const STREAM_RESULTS: &str = "RESULTS";
320pub const STREAM_EXEC: &str = "EXEC";
321pub const STREAM_EVENTS: &str = "EVENTS";
322pub const STREAM_AUDIT: &str = "AUDIT";
323
324/// JetStream stream retaining end-user notification history (SPEC
325/// §2.3.1 / Phase E). Catches every `notifications.{all|group.X|pc.Y}`
326/// publish the backend fans out, so a Client App that connects after
327/// a notification was sent can still fetch it via KLP
328/// `notifications.list`. 90-day window — long enough for "what did I
329/// miss while on leave" without unbounded growth. Unlike `EXEC`,
330/// retains all messages per subject (no `max_messages_per_subject`):
331/// each notification is its own history entry, not a latest-only state.
332pub const STREAM_NOTIFICATIONS: &str = "NOTIFICATIONS";
333
334/// JetStream stream backing the per-PC observability event pipeline
335/// (Issue #246). Distinct from [`STREAM_EVENTS`] (in-flight script
336/// lifecycle) — `STREAM_OBS_EVENTS` carries the timeline data the
337/// SPA's Events page consumes: sign-in/out, power on/off, sleep/
338/// resume, agent milestones, diagnostic bundle pointers. The agent
339/// publishes on `obs.<pc_id>` (see [`crate::subject::obs`]) and
340/// this stream catches everything matching [`crate::subject::OBS_FILTER`]
341/// so a backend that boots after the agent doesn't miss any
342/// already-emitted events.
343pub const STREAM_OBS_EVENTS: &str = "OBS_EVENTS";
344
345/// Canonical list of every JetStream resource
346/// [`crate::bootstrap::ensure_jetstream_resources`] creates. The health
347/// rollup (`/api/health/fleet`) and the status snapshot
348/// (`/api/jetstream/status`) both iterate these so the dashboard reports
349/// the *complete* resource set — previously each kept its own hand-
350/// maintained subset that drifted behind bootstrap (e.g. only 1 of the 5
351/// object stores showed up). Keep in lockstep with `bootstrap.rs`: a new
352/// stream / bucket / store added there must be appended here too. The
353/// `canonical_resource_lists_are_sane` test below guards the easy
354/// mistakes (dots, dupes, empties); keeping the *set* aligned with
355/// bootstrap stays a manual discipline (bootstrap needs per-resource
356/// config, so it can't be derived from a name list alone).
357pub const ALL_STREAMS: &[&str] = &[
358    STREAM_INVENTORY,
359    STREAM_RESULTS,
360    STREAM_EXEC,
361    STREAM_EVENTS,
362    STREAM_AUDIT,
363    STREAM_OBS_EVENTS,
364    STREAM_NOTIFICATIONS,
365];
366
367/// Every KV bucket `ensure_jetstream_resources` creates. The `*_yaml`
368/// source-of-truth buckets and the operator-managed singletons
369/// (`fleet_config`, `group_contacts`) are included — they're part of the
370/// bootstrap contract, so a missing one is a genuine degradation. Lazily-
371/// created buckets that bootstrap does NOT guarantee (e.g. `views`,
372/// `scheduler_dispatch`) are deliberately excluded so a fresh fleet that
373/// never used them doesn't read as degraded.
374pub const ALL_KV_BUCKETS: &[&str] = &[
375    BUCKET_SCRIPT_CURRENT,
376    BUCKET_SCRIPT_STATUS,
377    BUCKET_AGENTS_STATE,
378    BUCKET_AGENT_CONFIG,
379    BUCKET_AGENT_GROUPS,
380    BUCKET_AGENT_META,
381    BUCKET_GROUP_CONTACTS,
382    BUCKET_SCHEDULES,
383    BUCKET_JOBS,
384    BUCKET_FLEET_CONFIG,
385    BUCKET_NOTIFICATIONS_READ,
386    BUCKET_JOBS_YAML,
387    BUCKET_SCHEDULES_YAML,
388];
389
390/// Every Object Store `ensure_jetstream_resources` creates. The status
391/// probe used to list only `agent_releases`, which is why the dashboard's
392/// "Object stores" column looked suspiciously empty.
393pub const ALL_OBJECT_STORES: &[&str] = &[
394    OBJECT_AGENT_RELEASES,
395    OBJECT_APP_PACKAGES,
396    OBJECT_SCRIPTS,
397    OBJECT_RESULT_OUTPUT,
398    OBJECT_COLLECTIONS,
399];
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    /// NATS KV bucket names must be domain-safe ASCII (a-z, A-Z, 0-9, _, -).
406    /// Lock the constants down so a future edit doesn't introduce a `.` and
407    /// break create_key_value silently on the broker side.
408    #[test]
409    fn bucket_names_are_domain_safe() {
410        for name in [
411            BUCKET_SCRIPT_CURRENT,
412            BUCKET_SCRIPT_STATUS,
413            BUCKET_AGENTS_STATE,
414            BUCKET_AGENT_CONFIG,
415            BUCKET_AGENT_GROUPS,
416            BUCKET_AGENT_META,
417            BUCKET_GROUP_CONTACTS,
418            BUCKET_SCHEDULES,
419            BUCKET_JOBS,
420            BUCKET_JOBS_YAML,
421            BUCKET_SCHEDULES_YAML,
422            BUCKET_VIEWS,
423            BUCKET_VIEWS_YAML,
424            BUCKET_FLEET_CONFIG,
425            BUCKET_SERVER_SETTINGS,
426            BUCKET_NOTIFICATIONS_READ,
427            BUCKET_SCHEDULER_DISPATCH,
428            OBJECT_AGENT_RELEASES,
429            OBJECT_APP_PACKAGES,
430            OBJECT_SCRIPTS,
431            OBJECT_RESULT_OUTPUT,
432            OBJECT_COLLECTIONS,
433        ] {
434            assert!(
435                !name.contains('.'),
436                "bucket name {name:?} contains a dot, which NATS KV rejects"
437            );
438            assert!(
439                name.chars()
440                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
441                "bucket name {name:?} has non-domain-safe characters"
442            );
443        }
444    }
445
446    #[test]
447    fn stream_names_are_unique() {
448        let names = [
449            STREAM_INVENTORY,
450            STREAM_RESULTS,
451            STREAM_EXEC,
452            STREAM_EVENTS,
453            STREAM_AUDIT,
454            STREAM_OBS_EVENTS,
455            STREAM_NOTIFICATIONS,
456        ];
457        let mut deduped = names.to_vec();
458        deduped.sort_unstable();
459        deduped.dedup();
460        assert_eq!(
461            deduped.len(),
462            names.len(),
463            "stream constants collide: {names:?}"
464        );
465    }
466
467    /// The canonical lists the health + status probes iterate must be
468    /// non-empty, dup-free, and domain-safe (the same charset rule the
469    /// broker enforces). Catches a copy-paste dupe or a stray `.` before
470    /// it turns into a phantom "missing resource" on the dashboard.
471    #[test]
472    fn canonical_resource_lists_are_sane() {
473        for (label, list) in [
474            ("ALL_STREAMS", ALL_STREAMS),
475            ("ALL_KV_BUCKETS", ALL_KV_BUCKETS),
476            ("ALL_OBJECT_STORES", ALL_OBJECT_STORES),
477        ] {
478            assert!(!list.is_empty(), "{label} is empty");
479            let mut deduped = list.to_vec();
480            deduped.sort_unstable();
481            deduped.dedup();
482            assert_eq!(
483                deduped.len(),
484                list.len(),
485                "{label} has duplicates: {list:?}"
486            );
487            for name in list {
488                assert!(
489                    name.chars()
490                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
491                    "{label} entry {name:?} has non-domain-safe characters"
492                );
493            }
494        }
495    }
496
497    #[test]
498    fn notifications_read_key_and_prefix_align() {
499        let key = notifications_read_key("PC1234", "S-1-5-21-1001", "notif-9f3a");
500        assert_eq!(key, "PC1234.S-1-5-21-1001.notif-9f3a");
501        let prefix = notifications_read_prefix("PC1234", "S-1-5-21-1001");
502        assert_eq!(prefix, "PC1234.S-1-5-21-1001.");
503        // The list path selects a user's read set by this prefix — the
504        // key for any of that user's notifications must carry it.
505        assert!(key.starts_with(&prefix));
506        // A different user's key must NOT match the prefix.
507        let other = notifications_read_key("PC1234", "S-1-5-21-1002", "notif-9f3a");
508        assert!(!other.starts_with(&prefix));
509    }
510
511    #[test]
512    fn script_status_strings() {
513        assert_eq!(SCRIPT_STATUS_ACTIVE, "ACTIVE");
514        assert_eq!(SCRIPT_STATUS_REVOKED, "REVOKED");
515        assert_ne!(SCRIPT_STATUS_ACTIVE, SCRIPT_STATUS_REVOKED);
516    }
517
518    #[test]
519    fn key_agent_target_version_constant() {
520        assert_eq!(KEY_AGENT_TARGET_VERSION, "target_version");
521    }
522
523    #[test]
524    fn agent_config_group_key_round_trips() {
525        let k = agent_config_group_key("canary");
526        assert_eq!(k, "groups.canary");
527        assert_eq!(parse_agent_config_group_key(&k), Some("canary"));
528    }
529
530    #[test]
531    fn agent_config_pc_key_round_trips() {
532        let k = agent_config_pc_key("PC-01");
533        assert_eq!(k, "pcs.PC-01");
534        assert_eq!(parse_agent_config_pc_key(&k), Some("PC-01"));
535    }
536
537    #[test]
538    fn dispatch_mark_keys_are_distinct_by_kind() {
539        // The whole-target key for one schedule must never equal the
540        // per-pc key for another — the `pc.` / `target.` prefixes keep
541        // the two namespaces apart even when ids look alike.
542        let per_pc = dispatch_mark_pc_key("collect-winlog-events", "PC-01");
543        let target = dispatch_mark_target_key("collect-winlog-events");
544        assert_eq!(per_pc, "pc.21.collect-winlog-events.5.PC-01");
545        assert_eq!(target, "target.21.collect-winlog-events");
546        assert_ne!(per_pc, target);
547        // A schedule literally named "collect-winlog-events.PC-01"
548        // still can't collide with the per-pc key above.
549        assert_ne!(
550            dispatch_mark_target_key("collect-winlog-events.PC-01"),
551            per_pc,
552        );
553    }
554
555    #[test]
556    fn dispatch_mark_pc_key_has_no_dot_collision() {
557        // Length-prefixing makes the encoding injective: a dotted
558        // schedule_id can't borrow a leading segment from the pc_id (or
559        // vice versa) to forge a colliding key. (CodeRabbit / claude #444.)
560        assert_ne!(
561            dispatch_mark_pc_key("a.b", "c"),
562            dispatch_mark_pc_key("a", "b.c"),
563        );
564        assert_ne!(
565            dispatch_mark_pc_key("x", "y.z"),
566            dispatch_mark_pc_key("x.y", "z"),
567        );
568        // Same components, swapped roles — also distinct.
569        assert_ne!(
570            dispatch_mark_pc_key("foo", "bar"),
571            dispatch_mark_pc_key("bar", "foo"),
572        );
573    }
574
575    #[test]
576    fn agent_config_scope_keys_do_not_collide() {
577        // Belt + braces: make sure no pc id starting with "groups." would
578        // be misparsed (or vice versa). The prefixes are distinct because
579        // they each end in `.` and the parent buckets disagree on what
580        // comes after — pcs holds host names, groups holds membership
581        // names — but locking the invariant in a test stops a future
582        // rename from breaking it.
583        assert_ne!(PREFIX_AGENT_CONFIG_GROUPS, PREFIX_AGENT_CONFIG_PCS);
584        assert!(parse_agent_config_group_key("pcs.someone").is_none());
585        assert!(parse_agent_config_pc_key("groups.someone").is_none());
586        assert_eq!(parse_agent_config_group_key("global"), None);
587        assert_eq!(parse_agent_config_pc_key("global"), None);
588    }
589}