Skip to main content

cratefield_cli/
codes.rs

1//! The catalogue of every `fz` error code: stable kebab-case slugs an
2//! agent can branch on forever, the seed of the MCP server's "stable
3//! error-code catalogue" (issue #160). Modelled on
4//! `cratefield_core::problems`: a struct of `&'static` definitions, a
5//! registry function, stable string codes. Seeded by `fz doctor` (#220);
6//! the agent-safe workflow commands (`fz plan` / `deploy` / `add` /
7//! `init` / `verify`, harness #140) appended theirs.
8//!
9//! The contract: a code is never renamed, reworded or removed. A failure
10//! *message* may change wording freely; a *code* may not. New codes append.
11
12/// Definition of one `fz` error code.
13#[derive(Debug, Clone, Copy)]
14pub struct DoctorCodeDef {
15    /// The stable kebab-case code, exactly as `fz doctor --json` emits it.
16    pub code: &'static str,
17    /// Short, stable human title.
18    pub title: &'static str,
19    /// One-line description for consumers; not part of the JSON output.
20    pub description: &'static str,
21}
22
23/// Every code the doctor emits. Keep the registry below sorted by code.
24pub struct Codes {
25    /// A module was built against a different `HARNESS_API` than the
26    /// core crate it linked against.
27    pub harness_api_mismatch: DoctorCodeDef,
28    /// The `.harness-lock.json` could not be read or parsed.
29    pub lockfile_unreadable: DoctorCodeDef,
30    /// A module migration has no locked file yet — run
31    /// `fz migrations collect` first.
32    pub migration_not_collected: DoctorCodeDef,
33    /// A locked migration's file is missing from disk.
34    pub locked_migration_missing: DoctorCodeDef,
35    /// A locked migration's file no longer matches its pinned sha256.
36    pub locked_migration_edited: DoctorCodeDef,
37    /// The production captcha rule: captcha-guarded public writes
38    /// without an effectively configured Captcha port.
39    pub captcha_not_effective: DoctorCodeDef,
40    /// The production payments rule: signature-guarded routes without
41    /// `STRIPE_WEBHOOK_SECRET`.
42    pub payments_webhook_secret_missing: DoctorCodeDef,
43    /// A sidecar mount names a module compiled into this venture; the
44    /// runtime ignores the mount.
45    pub sidecar_shadows_module: DoctorCodeDef,
46    /// The sidecar mount table could not be parsed.
47    pub sidecar_mount_invalid: DoctorCodeDef,
48    /// A module found a problem in its own embedded data — a catalog
49    /// missing a translation it declared, and anything else a module
50    /// reports from `Module::self_check`.
51    pub module_self_check: DoctorCodeDef,
52    /// A migration contains SQL outside the portable subset.
53    pub non_portable_sql: DoctorCodeDef,
54    /// A migration contains card data (number, verification code or
55    /// full expiry).
56    pub card_data_in_migration: DoctorCodeDef,
57    /// A push transport is half-wired, or its credentials were refused:
58    /// either way it routes nothing and every send to it reports
59    /// `NotConfigured`.
60    pub push_transport_misconfigured: DoctorCodeDef,
61    /// The production push rule: modules that `requires()` the Push port
62    /// on a venture whose environment routes no transport at all.
63    pub push_required_but_unrouted: DoctorCodeDef,
64    /// The workflow commands (#140): `fz add` / `fz init` name a slug
65    /// the catalog does not carry.
66    pub module_unknown: DoctorCodeDef,
67    /// `fz verify`: no deployment is recorded for this manifest.
68    pub not_deployed: DoctorCodeDef,
69    /// A production deploy without the explicit
70    /// `--i-am-deploying-to-production` consent flag.
71    pub production_deploy_unauthorized: DoctorCodeDef,
72    /// `fz deploy --plan <digest>`: the digest does not match a plan
73    /// recomputed from the current inputs.
74    pub stale_plan: DoctorCodeDef,
75    /// `fz verify`: the recorded deployment's module set no longer
76    /// matches the manifest's resolved composition.
77    pub composition_drift: DoctorCodeDef,
78    /// `fz verify`: the recorded deployment's config no longer matches
79    /// the manifest's.
80    pub config_drift: DoctorCodeDef,
81    /// `fz deploy` was run without `--plan <digest>`: approval is the
82    /// point, so there is no default.
83    pub deploy_plan_required: DoctorCodeDef,
84    /// The `.harness-deploy.json` record could not be read or parsed.
85    pub deploy_record_unreadable: DoctorCodeDef,
86    /// The plan removes modules from the served composition (their data
87    /// leaves the venture) without the explicit destructive-change
88    /// consent flag.
89    pub destructive_change_unauthorized: DoctorCodeDef,
90    /// `fz verify`: the resolved deployment environment changed since
91    /// the recorded deployment.
92    pub env_drift: DoctorCodeDef,
93    /// `fz verify`: the recorded digest no longer matches a plan
94    /// recomputed now, and no narrower drift (composition, config, env)
95    /// explains it — the change is in the venture's identity or seed.
96    pub manifest_drift: DoctorCodeDef,
97    /// `fz init` was pointed at a manifest that already exists, without
98    /// `--force`.
99    pub manifest_exists: DoctorCodeDef,
100    /// A manifest parsed but does not resolve: empty name/host, a
101    /// duplicate module, or a selection the catalog refuses.
102    pub manifest_invalid: DoctorCodeDef,
103    /// The venture manifest could not be read or parsed.
104    pub manifest_unreadable: DoctorCodeDef,
105    /// A manifest could not be written back to disk.
106    pub manifest_write_failed: DoctorCodeDef,
107}
108
109pub const CODES: Codes = Codes {
110    harness_api_mismatch: DoctorCodeDef {
111        code: "harness-api-mismatch",
112        title: "Harness API mismatch",
113        description: "A module was built against a different HARNESS_API than the core crate this venture linked.",
114    },
115    lockfile_unreadable: DoctorCodeDef {
116        code: "lockfile-unreadable",
117        title: "Lockfile unreadable",
118        description: "The .harness-lock.json could not be read or parsed.",
119    },
120    migration_not_collected: DoctorCodeDef {
121        code: "migration-not-collected",
122        title: "Migration not collected",
123        description: "A module migration has no locked file yet; run `fz migrations collect`.",
124    },
125    locked_migration_missing: DoctorCodeDef {
126        code: "locked-migration-missing",
127        title: "Locked migration missing",
128        description: "A locked migration's file is missing from disk.",
129    },
130    locked_migration_edited: DoctorCodeDef {
131        code: "locked-migration-edited",
132        title: "Locked migration edited",
133        description: "A locked migration's file no longer matches its pinned sha256; restore it or add a new migration.",
134    },
135    captcha_not_effective: DoctorCodeDef {
136        code: "captcha-not-effective",
137        title: "Captcha not effective",
138        description: "A production venture has captcha-guarded public writes but the Captcha port is not effectively configured.",
139    },
140    payments_webhook_secret_missing: DoctorCodeDef {
141        code: "payments-webhook-secret-missing",
142        title: "Payments webhook secret missing",
143        description: "A production venture has signature-guarded routes but STRIPE_WEBHOOK_SECRET is unset.",
144    },
145    sidecar_shadows_module: DoctorCodeDef {
146        code: "sidecar-shadows-module",
147        title: "Sidecar shadows a compiled-in module",
148        description: "A sidecar mount names a module compiled into this venture; the runtime ignores the mount.",
149    },
150    sidecar_mount_invalid: DoctorCodeDef {
151        code: "sidecar-mount-invalid",
152        title: "Sidecar mount invalid",
153        description: "The sidecar mount table could not be parsed.",
154    },
155    module_self_check: DoctorCodeDef {
156        code: "module-self-check",
157        title: "Module self-check failed",
158        description: "A module reported a problem in its own embedded data, such as a \
159                      localisation catalog missing a message it declared.",
160    },
161    non_portable_sql: DoctorCodeDef {
162        code: "non-portable-sql",
163        title: "Non-portable SQL",
164        description: "A migration contains SQL outside the portable subset (dialect-specific tokens).",
165    },
166    card_data_in_migration: DoctorCodeDef {
167        code: "card-data-in-migration",
168        title: "Card data in migration",
169        description: "A migration contains card data: a card number, verification code or full expiry.",
170    },
171    push_transport_misconfigured: DoctorCodeDef {
172        code: "push-transport-misconfigured",
173        title: "Push transport misconfigured",
174        description: "A push transport has some of its variables set and some unset, or its credentials were refused; it is left unrouted.",
175    },
176    push_required_but_unrouted: DoctorCodeDef {
177        code: "push-required-but-unrouted",
178        title: "Push required but unrouted",
179        description: "A production venture has modules that require the Push port but its environment routes no push transport.",
180    },
181    module_unknown: DoctorCodeDef {
182        code: "module-unknown",
183        title: "Module unknown",
184        description: "`fz add` / `fz init` name a slug the catalog does not carry.",
185    },
186    not_deployed: DoctorCodeDef {
187        code: "not-deployed",
188        title: "Not deployed",
189        description: "`fz verify` finds no deployment recorded for this manifest.",
190    },
191    production_deploy_unauthorized: DoctorCodeDef {
192        code: "production-deploy-unauthorized",
193        title: "Production deploy unauthorized",
194        description: "A production deploy without the explicit --i-am-deploying-to-production consent flag.",
195    },
196    stale_plan: DoctorCodeDef {
197        code: "stale-plan",
198        title: "Stale plan",
199        description: "`fz deploy --plan <digest>`: the digest does not match a plan recomputed from the current inputs.",
200    },
201    composition_drift: DoctorCodeDef {
202        code: "composition-drift",
203        title: "Composition drift",
204        description: "The recorded deployment's module set no longer matches the manifest's resolved composition.",
205    },
206    config_drift: DoctorCodeDef {
207        code: "config-drift",
208        title: "Config drift",
209        description: "The recorded deployment's config no longer matches the manifest's.",
210    },
211    deploy_plan_required: DoctorCodeDef {
212        code: "deploy-plan-required",
213        title: "Deploy plan required",
214        description: "`fz deploy` refuses to run without --plan <digest>; approval is the point.",
215    },
216    deploy_record_unreadable: DoctorCodeDef {
217        code: "deploy-record-unreadable",
218        title: "Deploy record unreadable",
219        description: "The .harness-deploy.json record could not be read or parsed.",
220    },
221    destructive_change_unauthorized: DoctorCodeDef {
222        code: "destructive-change-unauthorized",
223        title: "Destructive change unauthorized",
224        description: "The plan removes modules (their data leaves the venture) without the explicit consent flag.",
225    },
226    env_drift: DoctorCodeDef {
227        code: "env-drift",
228        title: "Environment drift",
229        description: "The resolved deployment environment changed since the recorded deployment.",
230    },
231    manifest_drift: DoctorCodeDef {
232        code: "manifest-drift",
233        title: "Manifest drift",
234        description: "The recorded digest no longer matches a recomputed plan and no narrower drift explains it.",
235    },
236    manifest_exists: DoctorCodeDef {
237        code: "manifest-exists",
238        title: "Manifest exists",
239        description: "`fz init` was pointed at an existing manifest without --force.",
240    },
241    manifest_invalid: DoctorCodeDef {
242        code: "manifest-invalid",
243        title: "Manifest invalid",
244        description: "A manifest parsed but does not resolve: empty name/host, duplicate module, or a refused selection.",
245    },
246    manifest_unreadable: DoctorCodeDef {
247        code: "manifest-unreadable",
248        title: "Manifest unreadable",
249        description: "The venture manifest could not be read or parsed.",
250    },
251    manifest_write_failed: DoctorCodeDef {
252        code: "manifest-write-failed",
253        title: "Manifest write failed",
254        description: "A manifest could not be written back to disk.",
255    },
256};
257
258/// Every `fz` code definition, for tests and docs. Sorted by code.
259#[must_use]
260pub fn registry() -> Vec<&'static DoctorCodeDef> {
261    vec![
262        &CODES.captcha_not_effective,
263        &CODES.card_data_in_migration,
264        &CODES.composition_drift,
265        &CODES.config_drift,
266        &CODES.deploy_plan_required,
267        &CODES.deploy_record_unreadable,
268        &CODES.destructive_change_unauthorized,
269        &CODES.env_drift,
270        &CODES.harness_api_mismatch,
271        &CODES.locked_migration_edited,
272        &CODES.locked_migration_missing,
273        &CODES.lockfile_unreadable,
274        &CODES.manifest_drift,
275        &CODES.manifest_exists,
276        &CODES.manifest_invalid,
277        &CODES.manifest_unreadable,
278        &CODES.manifest_write_failed,
279        &CODES.migration_not_collected,
280        &CODES.module_self_check,
281        &CODES.module_unknown,
282        &CODES.non_portable_sql,
283        &CODES.not_deployed,
284        &CODES.payments_webhook_secret_missing,
285        &CODES.production_deploy_unauthorized,
286        &CODES.push_required_but_unrouted,
287        &CODES.push_transport_misconfigured,
288        &CODES.sidecar_mount_invalid,
289        &CODES.sidecar_shadows_module,
290        &CODES.stale_plan,
291    ]
292}
293
294#[cfg(test)]
295mod tests {
296    use super::{CODES, registry};
297
298    /// A code is lowercase kebab-case: an agent regexes it, so it must
299    /// stay machine-shaped forever.
300    #[test]
301    fn codes_are_kebab_case() {
302        for def in registry() {
303            assert!(
304                !def.code.is_empty()
305                    && def
306                        .code
307                        .bytes()
308                        .all(|byte| byte.is_ascii_lowercase() || byte == b'-')
309                    && !def.code.starts_with('-')
310                    && !def.code.ends_with('-')
311                    && !def.code.contains("--"),
312                "{:?} must be lowercase kebab-case",
313                def.code
314            );
315            assert!(!def.title.is_empty(), "{:?} needs a title", def.code);
316            assert!(
317                !def.description.is_empty(),
318                "{:?} needs a description",
319                def.code
320            );
321        }
322    }
323
324    /// Every catalogue field is registered exactly once — the registry
325    /// is the whole catalogue, and a code that drifts from its slug
326    /// fails here.
327    #[test]
328    fn registry_covers_every_field_exactly_once_and_is_sorted() {
329        let registered = registry();
330        let mut by_slug: Vec<&str> = registered.iter().map(|def| def.code).collect();
331        let mut expected = by_slug.clone();
332        expected.sort_unstable();
333        assert_eq!(by_slug, expected, "registry must stay sorted by code");
334        by_slug.dedup();
335        assert_eq!(by_slug.len(), registered.len(), "duplicate code registered");
336
337        let fields = [
338            CODES.harness_api_mismatch.code,
339            CODES.lockfile_unreadable.code,
340            CODES.migration_not_collected.code,
341            CODES.locked_migration_missing.code,
342            CODES.locked_migration_edited.code,
343            CODES.captcha_not_effective.code,
344            CODES.payments_webhook_secret_missing.code,
345            CODES.sidecar_shadows_module.code,
346            CODES.sidecar_mount_invalid.code,
347            CODES.non_portable_sql.code,
348            CODES.card_data_in_migration.code,
349            CODES.push_transport_misconfigured.code,
350            CODES.push_required_but_unrouted.code,
351            CODES.module_self_check.code,
352            CODES.module_unknown.code,
353            CODES.not_deployed.code,
354            CODES.production_deploy_unauthorized.code,
355            CODES.stale_plan.code,
356            CODES.composition_drift.code,
357            CODES.config_drift.code,
358            CODES.deploy_plan_required.code,
359            CODES.deploy_record_unreadable.code,
360            CODES.destructive_change_unauthorized.code,
361            CODES.env_drift.code,
362            CODES.manifest_drift.code,
363            CODES.manifest_exists.code,
364            CODES.manifest_invalid.code,
365            CODES.manifest_unreadable.code,
366            CODES.manifest_write_failed.code,
367        ];
368        assert_eq!(fields.len(), registered.len());
369        for field in fields {
370            assert!(
371                registered.iter().any(|def| def.code == field),
372                "{field} is in the catalogue but not the registry"
373            );
374        }
375    }
376}