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    /// The production auth rule: a module that must identify a caller,
44    /// in a deployment that cannot.
45    pub auth_not_configured: DoctorCodeDef,
46    /// A sidecar mount names a module compiled into this venture; the
47    /// runtime ignores the mount.
48    pub sidecar_shadows_module: DoctorCodeDef,
49    /// The sidecar mount table could not be parsed.
50    pub sidecar_mount_invalid: DoctorCodeDef,
51    /// A module found a problem in its own embedded data — a catalog
52    /// missing a translation it declared, and anything else a module
53    /// reports from `Module::self_check`.
54    pub module_self_check: DoctorCodeDef,
55    /// A migration contains SQL outside the portable subset.
56    pub non_portable_sql: DoctorCodeDef,
57    /// A migration contains card data (number, verification code or
58    /// full expiry).
59    pub card_data_in_migration: DoctorCodeDef,
60    /// A push transport is half-wired, or its credentials were refused:
61    /// either way it routes nothing and every send to it reports
62    /// `NotConfigured`.
63    pub push_transport_misconfigured: DoctorCodeDef,
64    /// The production push rule: modules that `requires()` the Push port
65    /// on a venture whose environment routes no transport at all.
66    pub push_required_but_unrouted: DoctorCodeDef,
67    /// The workflow commands (#140): `fz add` / `fz init` name a slug
68    /// the catalog does not carry.
69    pub module_unknown: DoctorCodeDef,
70    /// `fz verify`: no deployment is recorded for this manifest.
71    pub not_deployed: DoctorCodeDef,
72    /// A production deploy without the explicit
73    /// `--i-am-deploying-to-production` consent flag.
74    pub production_deploy_unauthorized: DoctorCodeDef,
75    /// `fz deploy --plan <digest>`: the digest does not match a plan
76    /// recomputed from the current inputs.
77    pub stale_plan: DoctorCodeDef,
78    /// `fz verify`: the recorded deployment's module set no longer
79    /// matches the manifest's resolved composition.
80    pub composition_drift: DoctorCodeDef,
81    /// `fz verify`: the recorded deployment's config no longer matches
82    /// the manifest's.
83    pub config_drift: DoctorCodeDef,
84    /// `fz deploy` was run without `--plan <digest>`: approval is the
85    /// point, so there is no default.
86    pub deploy_plan_required: DoctorCodeDef,
87    /// The `.harness-deploy.json` record could not be read or parsed.
88    pub deploy_record_unreadable: DoctorCodeDef,
89    /// The plan removes modules from the served composition without the
90    /// explicit `--i-am-removing-modules` consent flag; they stop being
91    /// served and no data is deleted.
92    pub destructive_change_unauthorized: DoctorCodeDef,
93    /// `fz verify`: the resolved deployment environment changed since
94    /// the recorded deployment.
95    pub env_drift: DoctorCodeDef,
96    /// `fz verify`: the recorded digest no longer matches a plan
97    /// recomputed now, and no narrower drift (composition, config, env)
98    /// explains it — the change is in the venture's identity or seed.
99    pub manifest_drift: DoctorCodeDef,
100    /// `fz init` was pointed at a manifest that already exists, without
101    /// `--force`.
102    pub manifest_exists: DoctorCodeDef,
103    /// A manifest parsed but does not resolve: empty name/host, a
104    /// duplicate module, or a selection the catalog refuses.
105    pub manifest_invalid: DoctorCodeDef,
106    /// The venture manifest could not be read or parsed.
107    pub manifest_unreadable: DoctorCodeDef,
108    /// A manifest could not be written back to disk.
109    pub manifest_write_failed: DoctorCodeDef,
110    /// `fz client-ts` (issue #155): a generated file could not be written
111    /// to `--out`.
112    pub client_write_failed: DoctorCodeDef,
113    /// `fz client-ts`: the tables module publishes an action outside the
114    /// five-verb vocabulary (`list-`/`read-`/`create-`/`replace-`/
115    /// `delete-`, then the table name) the generator reads.
116    pub surface_action_unreadable: DoctorCodeDef,
117    /// `fz client-ts`: the document parsed as JSON but is not a
118    /// `/__surface` document.
119    pub surface_invalid: DoctorCodeDef,
120    /// `fz client-ts`: the document declares no `tables` module, so there
121    /// is no contract to generate a client from.
122    pub surface_no_tables_module: DoctorCodeDef,
123    /// `fz client-ts`: the tables module publishes a table whose name is
124    /// not a safe identifier, which the generator would interpolate into
125    /// the generated TypeScript.
126    pub surface_table_name_invalid: DoctorCodeDef,
127    /// `fz client-ts`: the `/__surface` document could not be read, or is
128    /// not valid JSON.
129    pub surface_unreadable: DoctorCodeDef,
130    /// `fz client-ts`: the document speaks a `surface_api` contract
131    /// version this generator does not read.
132    pub surface_unsupported_contract: DoctorCodeDef,
133    /// `ADMIN_TOKEN` is set but shorter than the 32-byte floor
134    /// `HarnessConfig::from_config` enforces in every environment
135    /// (issue #437).
136    pub admin_token_too_short: DoctorCodeDef,
137    /// The production rate-limiter rule (issue #437): a venture with
138    /// public writes or admin routes on a runtime whose `RateLimiter` port
139    /// is not resolved, and no recorded acceptance.
140    pub rate_limiter_not_effective: DoctorCodeDef,
141    /// The production webhook-secret rule (issue #533): a module that
142    /// verifies its signature routes with the core `webhook_signature`
143    /// HMAC scheme, in a deployment that has not set the secret key it
144    /// named.
145    pub hmac_webhook_secret_missing: DoctorCodeDef,
146}
147
148pub const CODES: Codes = Codes {
149    harness_api_mismatch: DoctorCodeDef {
150        code: "harness-api-mismatch",
151        title: "Harness API mismatch",
152        description: "A module was built against a different HARNESS_API than the core crate this venture linked.",
153    },
154    lockfile_unreadable: DoctorCodeDef {
155        code: "lockfile-unreadable",
156        title: "Lockfile unreadable",
157        description: "The .harness-lock.json could not be read or parsed.",
158    },
159    migration_not_collected: DoctorCodeDef {
160        code: "migration-not-collected",
161        title: "Migration not collected",
162        description: "A module migration has no locked file yet; run `fz migrations collect`.",
163    },
164    locked_migration_missing: DoctorCodeDef {
165        code: "locked-migration-missing",
166        title: "Locked migration missing",
167        description: "A locked migration's file is missing from disk.",
168    },
169    locked_migration_edited: DoctorCodeDef {
170        code: "locked-migration-edited",
171        title: "Locked migration edited",
172        description: "A locked migration's file no longer matches its pinned sha256; restore it or add a new migration.",
173    },
174    captcha_not_effective: DoctorCodeDef {
175        code: "captcha-not-effective",
176        title: "Captcha not effective",
177        description: "A production venture has captcha-guarded public writes but the Captcha port is not effectively configured.",
178    },
179    payments_webhook_secret_missing: DoctorCodeDef {
180        code: "payments-webhook-secret-missing",
181        title: "Payments webhook secret missing",
182        description: "A production venture has signature-guarded routes but STRIPE_WEBHOOK_SECRET is unset.",
183    },
184    auth_not_configured: DoctorCodeDef {
185        code: "auth-not-configured",
186        title: "Cannot identify a caller",
187        description: "A production venture mounts a module that requires the Auth port, but AUTH_ISSUER or AUTH_CLIENT_ID is unset — every route needing a caller answers 503.",
188    },
189    sidecar_shadows_module: DoctorCodeDef {
190        code: "sidecar-shadows-module",
191        title: "Sidecar shadows a compiled-in module",
192        description: "A sidecar mount names a module compiled into this venture; the runtime ignores the mount.",
193    },
194    sidecar_mount_invalid: DoctorCodeDef {
195        code: "sidecar-mount-invalid",
196        title: "Sidecar mount invalid",
197        description: "The sidecar mount table could not be parsed.",
198    },
199    module_self_check: DoctorCodeDef {
200        code: "module-self-check",
201        title: "Module self-check failed",
202        description: "A module reported a problem in its own embedded data, such as a \
203                      localisation catalog missing a message it declared.",
204    },
205    non_portable_sql: DoctorCodeDef {
206        code: "non-portable-sql",
207        title: "Non-portable SQL",
208        description: "A migration contains SQL outside the portable subset (dialect-specific tokens).",
209    },
210    card_data_in_migration: DoctorCodeDef {
211        code: "card-data-in-migration",
212        title: "Card data in migration",
213        description: "A migration contains card data: a card number, verification code or full expiry.",
214    },
215    push_transport_misconfigured: DoctorCodeDef {
216        code: "push-transport-misconfigured",
217        title: "Push transport misconfigured",
218        description: "A push transport has some of its variables set and some unset, or its credentials were refused; it is left unrouted.",
219    },
220    push_required_but_unrouted: DoctorCodeDef {
221        code: "push-required-but-unrouted",
222        title: "Push required but unrouted",
223        description: "A production venture has modules that require the Push port but its environment routes no push transport.",
224    },
225    module_unknown: DoctorCodeDef {
226        code: "module-unknown",
227        title: "Module unknown",
228        description: "`fz add` / `fz init` name a slug the catalog does not carry.",
229    },
230    not_deployed: DoctorCodeDef {
231        code: "not-deployed",
232        title: "Not deployed",
233        description: "`fz verify` finds no deployment recorded for this manifest.",
234    },
235    production_deploy_unauthorized: DoctorCodeDef {
236        code: "production-deploy-unauthorized",
237        title: "Production deploy unauthorized",
238        description: "A production deploy without the explicit --i-am-deploying-to-production consent flag.",
239    },
240    stale_plan: DoctorCodeDef {
241        code: "stale-plan",
242        title: "Stale plan",
243        description: "`fz deploy --plan <digest>`: the digest does not match a plan recomputed from the current inputs.",
244    },
245    composition_drift: DoctorCodeDef {
246        code: "composition-drift",
247        title: "Composition drift",
248        description: "The recorded deployment's module set no longer matches the manifest's resolved composition.",
249    },
250    config_drift: DoctorCodeDef {
251        code: "config-drift",
252        title: "Config drift",
253        description: "The recorded deployment's config no longer matches the manifest's.",
254    },
255    deploy_plan_required: DoctorCodeDef {
256        code: "deploy-plan-required",
257        title: "Deploy plan required",
258        description: "`fz deploy` refuses to run without --plan <digest>; approval is the point.",
259    },
260    deploy_record_unreadable: DoctorCodeDef {
261        code: "deploy-record-unreadable",
262        title: "Deploy record unreadable",
263        description: "The .harness-deploy.json record could not be read or parsed.",
264    },
265    destructive_change_unauthorized: DoctorCodeDef {
266        code: "destructive-change-unauthorized",
267        title: "Destructive change unauthorized",
268        description: "The plan removes modules without the --i-am-removing-modules consent; they stop being served and no data is deleted.",
269    },
270    env_drift: DoctorCodeDef {
271        code: "env-drift",
272        title: "Environment drift",
273        description: "The resolved deployment environment changed since the recorded deployment.",
274    },
275    manifest_drift: DoctorCodeDef {
276        code: "manifest-drift",
277        title: "Manifest drift",
278        description: "The recorded digest no longer matches a recomputed plan and no narrower drift explains it.",
279    },
280    manifest_exists: DoctorCodeDef {
281        code: "manifest-exists",
282        title: "Manifest exists",
283        description: "`fz init` was pointed at an existing manifest without --force.",
284    },
285    manifest_invalid: DoctorCodeDef {
286        code: "manifest-invalid",
287        title: "Manifest invalid",
288        description: "A manifest parsed but does not resolve: empty name/host, duplicate module, or a refused selection.",
289    },
290    manifest_unreadable: DoctorCodeDef {
291        code: "manifest-unreadable",
292        title: "Manifest unreadable",
293        description: "The venture manifest could not be read or parsed.",
294    },
295    manifest_write_failed: DoctorCodeDef {
296        code: "manifest-write-failed",
297        title: "Manifest write failed",
298        description: "A manifest could not be written back to disk.",
299    },
300    client_write_failed: DoctorCodeDef {
301        code: "client-write-failed",
302        title: "Client write failed",
303        description: "A file of the generated TypeScript client could not be written to --out.",
304    },
305    surface_action_unreadable: DoctorCodeDef {
306        code: "surface-action-unreadable",
307        title: "Tables action unreadable",
308        description: "The tables module publishes an action outside the five-verb vocabulary the generator reads.",
309    },
310    surface_invalid: DoctorCodeDef {
311        code: "surface-invalid",
312        title: "Not a surface document",
313        description: "The document parsed as JSON but is not a /__surface document.",
314    },
315    surface_no_tables_module: DoctorCodeDef {
316        code: "surface-no-tables-module",
317        title: "No tables module",
318        description: "The document declares no `tables` module, so there is no contract to generate a client from.",
319    },
320    surface_table_name_invalid: DoctorCodeDef {
321        code: "surface-table-name-invalid",
322        title: "Table name unsupported",
323        description: "The tables module publishes a table whose name is not a safe identifier (lowercase [a-z][a-z0-9]*, no double or trailing underscore), which the generator would interpolate into the generated TypeScript.",
324    },
325    surface_unreadable: DoctorCodeDef {
326        code: "surface-unreadable",
327        title: "Surface document unreadable",
328        description: "The /__surface document could not be read, or is not valid JSON.",
329    },
330    surface_unsupported_contract: DoctorCodeDef {
331        code: "surface-unsupported-contract",
332        title: "Surface contract unsupported",
333        description: "The document speaks a surface_api contract version this generator does not read.",
334    },
335    admin_token_too_short: DoctorCodeDef {
336        code: "admin-token-too-short",
337        title: "Admin token too short",
338        description: "ADMIN_TOKEN is set but shorter than the 32-byte floor the boot enforces in every environment; lengthen it or remove it.",
339    },
340    rate_limiter_not_effective: DoctorCodeDef {
341        code: "rate-limiter-not-effective",
342        title: "Rate limiter not effective",
343        description: "A production venture takes public writes or admin routes but the RateLimiter port is not resolved; set HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES to a reason to serve unlimited.",
344    },
345    hmac_webhook_secret_missing: DoctorCodeDef {
346        code: "hmac-webhook-secret-missing",
347        title: "Webhook signing secret missing",
348        description: "A production venture has a module verifying its signature routes with the core webhook_signature HMAC scheme, but the module-scoped secret key it named is unset; its handler refuses every delivery.",
349    },
350};
351
352/// Every `fz` code definition, for tests and docs. Sorted by code.
353#[must_use]
354pub fn registry() -> Vec<&'static DoctorCodeDef> {
355    vec![
356        &CODES.admin_token_too_short,
357        &CODES.auth_not_configured,
358        &CODES.captcha_not_effective,
359        &CODES.card_data_in_migration,
360        &CODES.client_write_failed,
361        &CODES.composition_drift,
362        &CODES.config_drift,
363        &CODES.deploy_plan_required,
364        &CODES.deploy_record_unreadable,
365        &CODES.destructive_change_unauthorized,
366        &CODES.env_drift,
367        &CODES.harness_api_mismatch,
368        &CODES.hmac_webhook_secret_missing,
369        &CODES.locked_migration_edited,
370        &CODES.locked_migration_missing,
371        &CODES.lockfile_unreadable,
372        &CODES.manifest_drift,
373        &CODES.manifest_exists,
374        &CODES.manifest_invalid,
375        &CODES.manifest_unreadable,
376        &CODES.manifest_write_failed,
377        &CODES.migration_not_collected,
378        &CODES.module_self_check,
379        &CODES.module_unknown,
380        &CODES.non_portable_sql,
381        &CODES.not_deployed,
382        &CODES.payments_webhook_secret_missing,
383        &CODES.production_deploy_unauthorized,
384        &CODES.push_required_but_unrouted,
385        &CODES.push_transport_misconfigured,
386        &CODES.rate_limiter_not_effective,
387        &CODES.sidecar_mount_invalid,
388        &CODES.sidecar_shadows_module,
389        &CODES.stale_plan,
390        &CODES.surface_action_unreadable,
391        &CODES.surface_invalid,
392        &CODES.surface_no_tables_module,
393        &CODES.surface_table_name_invalid,
394        &CODES.surface_unreadable,
395        &CODES.surface_unsupported_contract,
396    ]
397}
398
399#[cfg(test)]
400mod tests {
401    use super::{CODES, registry};
402
403    /// A code is lowercase kebab-case: an agent regexes it, so it must
404    /// stay machine-shaped forever.
405    #[test]
406    fn codes_are_kebab_case() {
407        for def in registry() {
408            assert!(
409                !def.code.is_empty()
410                    && def
411                        .code
412                        .bytes()
413                        .all(|byte| byte.is_ascii_lowercase() || byte == b'-')
414                    && !def.code.starts_with('-')
415                    && !def.code.ends_with('-')
416                    && !def.code.contains("--"),
417                "{:?} must be lowercase kebab-case",
418                def.code
419            );
420            assert!(!def.title.is_empty(), "{:?} needs a title", def.code);
421            assert!(
422                !def.description.is_empty(),
423                "{:?} needs a description",
424                def.code
425            );
426        }
427    }
428
429    /// Every catalogue field is registered exactly once — the registry
430    /// is the whole catalogue, and a code that drifts from its slug
431    /// fails here.
432    #[test]
433    fn registry_covers_every_field_exactly_once_and_is_sorted() {
434        let registered = registry();
435        let mut by_slug: Vec<&str> = registered.iter().map(|def| def.code).collect();
436        let mut expected = by_slug.clone();
437        expected.sort_unstable();
438        assert_eq!(by_slug, expected, "registry must stay sorted by code");
439        by_slug.dedup();
440        assert_eq!(by_slug.len(), registered.len(), "duplicate code registered");
441
442        let fields = [
443            CODES.admin_token_too_short.code,
444            CODES.auth_not_configured.code,
445            CODES.harness_api_mismatch.code,
446            CODES.hmac_webhook_secret_missing.code,
447            CODES.lockfile_unreadable.code,
448            CODES.migration_not_collected.code,
449            CODES.locked_migration_missing.code,
450            CODES.locked_migration_edited.code,
451            CODES.captcha_not_effective.code,
452            CODES.payments_webhook_secret_missing.code,
453            CODES.sidecar_shadows_module.code,
454            CODES.sidecar_mount_invalid.code,
455            CODES.non_portable_sql.code,
456            CODES.card_data_in_migration.code,
457            CODES.push_transport_misconfigured.code,
458            CODES.push_required_but_unrouted.code,
459            CODES.rate_limiter_not_effective.code,
460            CODES.module_self_check.code,
461            CODES.module_unknown.code,
462            CODES.not_deployed.code,
463            CODES.production_deploy_unauthorized.code,
464            CODES.stale_plan.code,
465            CODES.composition_drift.code,
466            CODES.config_drift.code,
467            CODES.deploy_plan_required.code,
468            CODES.deploy_record_unreadable.code,
469            CODES.destructive_change_unauthorized.code,
470            CODES.env_drift.code,
471            CODES.manifest_drift.code,
472            CODES.manifest_exists.code,
473            CODES.manifest_invalid.code,
474            CODES.manifest_unreadable.code,
475            CODES.manifest_write_failed.code,
476            CODES.client_write_failed.code,
477            CODES.surface_action_unreadable.code,
478            CODES.surface_invalid.code,
479            CODES.surface_no_tables_module.code,
480            CODES.surface_table_name_invalid.code,
481            CODES.surface_unreadable.code,
482            CODES.surface_unsupported_contract.code,
483        ];
484        assert_eq!(fields.len(), registered.len());
485        for field in fields {
486            assert!(
487                registered.iter().any(|def| def.code == field),
488                "{field} is in the catalogue but not the registry"
489            );
490        }
491    }
492}