Summary
Every caseworkctl command in the Authoring command class (init, source add,
check, explain, simulate, package, package --dry-run, test) that fails
with a plain anyhow::bail!/anyhow! error loses that error's own text entirely.
classify_failure in crates/registry-caseworkctl/src/lib.rs replaces it with one
fixed, generic sentence and a fixed, generic suggestedAction, in both text and
--format json output. The author never sees which file, field, or pinned value is
wrong; they are told to fix "the unavailable runtime dependency", which is
actively misleading for a purely offline, file-content refusal.
Reproduction
caseworkctl init /tmp/proj --template professional-review
# edit /tmp/proj's BReg source description so request.review.policyId names a
# reviewKinds id that casework.yaml does not declare (or use
# products/casework/examples/multi-stage-routing-clocks and repoint one of its
# sources/*.json review.policyId to a nonexistent id)
caseworkctl check /tmp/proj
Text output:
error[caseworkctl.refused] runtime: The Casework command was refused because an authored input did not satisfy its contract.
next: Correct the unavailable runtime dependency, then retry.
--format json output is no more informative:
{
"diagnostics": [
{
"artifact": "runtime_dependency",
"code": "caseworkctl.refused",
"message": "The Casework command was refused because an authored input did not satisfy its contract.",
"path": "runtime",
"severity": "error",
"suggestedAction": "Correct the unavailable runtime dependency, then retry."
}
],
"ok": false
}
I reproduced both outputs verbatim against a locally built caseworkctl. The refusal actually being swallowed here (from
check_source_review_binding in crates/registry-caseworkctl/src/project.rs) is:
source regional-register description sources/regional-register.json pins review.policyId "no-such-review-kind" at sourceRevision "sha256:...", but casework.yaml declares no reviewKinds[].id matching "no-such-review-kind" as pinned; declare a reviewKinds entry with id "no-such-review-kind" in casework.yaml, or re-run `caseworkctl source add` to repin a policy that resolves
None of that text, including the source id, the description path, the pinned
policyId, or the pinned sourceRevision, ever reaches the CLI's stdout/stderr.
Root cause
classify_failure in crates/registry-caseworkctl/src/lib.rs walks the error's
anyhow chain looking for a small set of recognized typed errors
(RuntimeConfigError, ConfigLoadError, ConfigError, std::io::Error). When it
finds one, it uses that type's own to_string() and a type-specific
artifact/path/suggestedAction tuple. When it finds none of those, which is
exactly what happens for a plain anyhow::bail!, it falls into the final else
arms:
(artifact, path, action) selection (around lines 447-453): hardcodes
("runtime_dependency", "runtime".to_owned(), "Correct the unavailable runtime dependency, then retry.").
message selection (around lines 481-483): hardcodes "The Casework command was refused because an authored input did not satisfy its contract." and discards
error.to_string() entirely.
operator_refusal, the function that gives Retention and AttemptSettlement
commands their own typed, safe-but-specific diagnostics (via StoreError and
AttemptSettlementError), explicitly returns None for
CommandKind::Authoring (and CommandKind::Operational) at line 540, so it never
gets a chance to rescue an authoring refusal either.
command_kind (lines 387-402) puts every command except Doctor, Db, Dev,
Retention, and Attempt into CommandKind::Authoring by default. That
confirms init, source add, check, explain, simulate, package (both
modes), and test all classify as Authoring and are all exposed to this bug;
run() (lines 766-808) routes every one of them through project::* or
source_add::run.
Impact, quantified
project.rs, policy.rs, and source_add.rs, the three modules that implement
every Authoring command, together contain 79 bail! call sites (20 in
project.rs, 6 in policy.rs, 53 in source_add.rs) that construct plain
anyhow::Errors. None of them can be recognized by classify_failure's typed-error
matching, so every one of them, when it fires through the CLI, has its message
discarded and replaced by the same generic sentence above. This is a design
property of the current classify_failure shape, not a per-call-site accident.
Open PR #1271 adds five more of them in check_source_review_binding, each
naming the source id, the rendered description path, the pinned policyId and
the pinned sourceRevision. All five are swallowed the same way. The refusals
are correct and the exit code is correct; it is only the text that never
arrives.
By contrast, dev/mod.rs, dev/integrations.rs, dev/config.rs, and
dev/private.rs (136 further bail! sites) back caseworkctl dev, which is
CommandKind::Operational; those refusals fall into a different generic
fallback ("A Casework runtime dependency check failed.") for the same structural
reason. That is a related but separate gap and is not the focus of this ticket.
Why this was not caught
No test asserts that a caseworkctl refusal's own text reaches the process's
stdout/stderr for an Authoring command:
products/casework/scripts/check-checkpoint.sh only runs caseworkctl against
projects that are expected to succeed (init, check, test, explain,
simulate, package, each redirected to /dev/null or asserted only on its
JSON report shape). It never exercises a deliberately broken project.
- The unit tests added for the review-policy-binding refusal
(check_source_descriptions_refuses_an_unresolved_review_policy_id and
siblings in crates/registry-caseworkctl/src/project.rs) call
check_source_descriptions(&project) directly and assert on
format!("{:#}", ...unwrap_err()). That is the internal Result, not the
CLI-level diagnostic that classify_failure/write_failure produce, so those
tests pass today and would keep passing even if the CLI-facing message were
completely blank.
- No test in
crates/registry-caseworkctl/src/lib.rs calls
classify_failure(CommandKind::Authoring, &error) at all. Every existing test
of classify_failure uses CommandKind::Operational, CommandKind::Retention,
or CommandKind::AttemptSettlement.
Proposed fix, and its trade-off
The direct fix is to stop discarding error.to_string() in the domain message
branch (around line 481) the same way the runtime_project_error/runtime_error/
project_error/semantic_error branches above it already do, and to give the
domain catch-all a more honest artifact/path/suggestedAction than
"runtime_dependency"/"runtime"/"Correct the unavailable runtime dependency,
then retry." (an authoring refusal is not a runtime dependency problem).
I searched the tree for the exact generic strings currently hardcoded there
("The Casework command was refused because an authored input did not satisfy its contract." and "Correct the unavailable runtime dependency, then retry.")
and found no other occurrence outside crates/registry-caseworkctl/src/lib.rs
itself, and no test that pins the artifact/path pair
("runtime_dependency"/"runtime") for an Authoring-class error; the one
existing test that does assert artifact == "runtime_dependency"
(unavailable_oidc_dependency_uses_operational_exit_and_safe_diagnostic) exercises
the unrelated RuntimeConfigError::Oidc branch, not this catch-all. So, as far as
I can verify, fixing the catch-all's message would not move any currently passing
test. I could not rule out that some downstream consumer outside this repository
depends on the exact generic wording; that risk is unavoidable without exercising
it, since this diagnostic shape is part of the CLI's public --format json
contract.
A narrower alternative, worth considering instead of widening the catch-all, is to
give authoring refusals their own typed error (the way ConfigError and
ConfigLoadError already do for other classes), so check_source_review_binding
and the rest of project.rs/policy.rs/source_add.rs return that type instead
of a bare bail!. That would let classify_failure report an accurate
artifact/path (e.g. the source description path) instead of just forwarding
to_string() into a still-generic "runtime" path, mirroring the existing
project_diagnostic_location/semantic_diagnostic_location pattern. It is more
work than the one-line message fix, but produces a materially better diagnostic
and keeps the typed-error convention this file already uses everywhere else. I'd
lean toward this shape for project.rs's own checks (they already have a
well-defined "which file" answer) and treat the plain .to_string() fallback as
the acceptable minimum for source_add.rs, which has many more, more varied call
sites.
Acceptance criteria
Summary
Every
caseworkctlcommand in theAuthoringcommand class (init,source add,check,explain,simulate,package,package --dry-run,test) that failswith a plain
anyhow::bail!/anyhow!error loses that error's own text entirely.classify_failureincrates/registry-caseworkctl/src/lib.rsreplaces it with onefixed, generic sentence and a fixed, generic
suggestedAction, in both text and--format jsonoutput. The author never sees which file, field, or pinned value iswrong; they are told to fix "the unavailable runtime dependency", which is
actively misleading for a purely offline, file-content refusal.
Reproduction
Text output:
--format jsonoutput is no more informative:{ "diagnostics": [ { "artifact": "runtime_dependency", "code": "caseworkctl.refused", "message": "The Casework command was refused because an authored input did not satisfy its contract.", "path": "runtime", "severity": "error", "suggestedAction": "Correct the unavailable runtime dependency, then retry." } ], "ok": false }I reproduced both outputs verbatim against a locally built
caseworkctl. The refusal actually being swallowed here (fromcheck_source_review_bindingincrates/registry-caseworkctl/src/project.rs) is:None of that text, including the source id, the description path, the pinned
policyId, or the pinnedsourceRevision, ever reaches the CLI's stdout/stderr.Root cause
classify_failureincrates/registry-caseworkctl/src/lib.rswalks the error'sanyhowchain looking for a small set of recognized typed errors(
RuntimeConfigError,ConfigLoadError,ConfigError,std::io::Error). When itfinds one, it uses that type's own
to_string()and a type-specificartifact/path/suggestedActiontuple. When it finds none of those, which isexactly what happens for a plain
anyhow::bail!, it falls into the finalelsearms:
(artifact, path, action)selection (around lines 447-453): hardcodes("runtime_dependency", "runtime".to_owned(), "Correct the unavailable runtime dependency, then retry.").messageselection (around lines 481-483): hardcodes"The Casework command was refused because an authored input did not satisfy its contract."and discardserror.to_string()entirely.operator_refusal, the function that givesRetentionandAttemptSettlementcommands their own typed, safe-but-specific diagnostics (via
StoreErrorandAttemptSettlementError), explicitly returnsNoneforCommandKind::Authoring(andCommandKind::Operational) at line 540, so it nevergets a chance to rescue an authoring refusal either.
command_kind(lines 387-402) puts every command exceptDoctor,Db,Dev,Retention, andAttemptintoCommandKind::Authoringby default. Thatconfirms
init,source add,check,explain,simulate,package(bothmodes), and
testall classify asAuthoringand are all exposed to this bug;run()(lines 766-808) routes every one of them throughproject::*orsource_add::run.Impact, quantified
project.rs,policy.rs, andsource_add.rs, the three modules that implementevery
Authoringcommand, together contain 79bail!call sites (20 inproject.rs, 6 inpolicy.rs, 53 insource_add.rs) that construct plainanyhow::Errors. None of them can be recognized byclassify_failure's typed-errormatching, so every one of them, when it fires through the CLI, has its message
discarded and replaced by the same generic sentence above. This is a design
property of the current
classify_failureshape, not a per-call-site accident.Open PR #1271 adds five more of them in
check_source_review_binding, eachnaming the source id, the rendered description path, the pinned
policyIdandthe pinned
sourceRevision. All five are swallowed the same way. The refusalsare correct and the exit code is correct; it is only the text that never
arrives.
By contrast,
dev/mod.rs,dev/integrations.rs,dev/config.rs, anddev/private.rs(136 furtherbail!sites) backcaseworkctl dev, which isCommandKind::Operational; those refusals fall into a different genericfallback ("A Casework runtime dependency check failed.") for the same structural
reason. That is a related but separate gap and is not the focus of this ticket.
Why this was not caught
No test asserts that a
caseworkctlrefusal's own text reaches the process'sstdout/stderr for an
Authoringcommand:products/casework/scripts/check-checkpoint.shonly runscaseworkctlagainstprojects that are expected to succeed (
init,check,test,explain,simulate,package, each redirected to/dev/nullor asserted only on itsJSON report shape). It never exercises a deliberately broken project.
(
check_source_descriptions_refuses_an_unresolved_review_policy_idandsiblings in
crates/registry-caseworkctl/src/project.rs) callcheck_source_descriptions(&project)directly and assert onformat!("{:#}", ...unwrap_err()). That is the internalResult, not theCLI-level diagnostic that
classify_failure/write_failureproduce, so thosetests pass today and would keep passing even if the CLI-facing message were
completely blank.
crates/registry-caseworkctl/src/lib.rscallsclassify_failure(CommandKind::Authoring, &error)at all. Every existing testof
classify_failureusesCommandKind::Operational,CommandKind::Retention,or
CommandKind::AttemptSettlement.Proposed fix, and its trade-off
The direct fix is to stop discarding
error.to_string()in thedomainmessagebranch (around line 481) the same way the
runtime_project_error/runtime_error/project_error/semantic_errorbranches above it already do, and to give thedomaincatch-all a more honestartifact/path/suggestedActionthan"runtime_dependency"/"runtime"/"Correct the unavailable runtime dependency,then retry." (an authoring refusal is not a runtime dependency problem).
I searched the tree for the exact generic strings currently hardcoded there
(
"The Casework command was refused because an authored input did not satisfy its contract."and"Correct the unavailable runtime dependency, then retry.")and found no other occurrence outside
crates/registry-caseworkctl/src/lib.rsitself, and no test that pins the
artifact/pathpair(
"runtime_dependency"/"runtime") for anAuthoring-class error; the oneexisting test that does assert
artifact == "runtime_dependency"(
unavailable_oidc_dependency_uses_operational_exit_and_safe_diagnostic) exercisesthe unrelated
RuntimeConfigError::Oidcbranch, not this catch-all. So, as far asI can verify, fixing the catch-all's message would not move any currently passing
test. I could not rule out that some downstream consumer outside this repository
depends on the exact generic wording; that risk is unavoidable without exercising
it, since this diagnostic shape is part of the CLI's public
--format jsoncontract.
A narrower alternative, worth considering instead of widening the catch-all, is to
give authoring refusals their own typed error (the way
ConfigErrorandConfigLoadErroralready do for other classes), socheck_source_review_bindingand the rest of
project.rs/policy.rs/source_add.rsreturn that type insteadof a bare
bail!. That would letclassify_failurereport an accurateartifact/path(e.g. the source description path) instead of just forwardingto_string()into a still-generic"runtime"path, mirroring the existingproject_diagnostic_location/semantic_diagnostic_locationpattern. It is morework than the one-line message fix, but produces a materially better diagnostic
and keeps the typed-error convention this file already uses everywhere else. I'd
lean toward this shape for
project.rs's own checks (they already have awell-defined "which file" answer) and treat the plain
.to_string()fallback asthe acceptable minimum for
source_add.rs, which has many more, more varied callsites.
Acceptance criteria
caseworkctl check(andexplain/simulate/package/test/init/source add) refusal raised by a plainbail!/anyhow!inproject.rs,policy.rs, orsource_add.rsprints its own message text onstdout/stderr, in both the default text format and
--format json.--format jsondiagnostic'sartifact/path/suggestedActionforsuch a refusal no longer claims a "runtime dependency" is unavailable.
Result-returning function) for at least one authoring refusal, and assertsthe refusal's own wording appears in the CLI's output.
products/casework/scripts/check-checkpoint.shor an adjacent testconfirms this for a deliberately broken project (a pinned review policy that no
longer resolves is one such project), not only for
the happy path it exercises today.
crates/registry-caseworkctl/src/lib.rsandcrates/registry-caseworkctl/src/project.rsstill pass; any test whoseexpected diagnostic text has to change is updated in the same change, with
the reason noted in the PR.