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