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