Skip to content

caseworkctl discards every authoring bail! refusal text behind a generic "runtime dependency" message #1272

Description

@jeremi

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

  • A caseworkctl check (and explain/simulate/package/test/init/
    source add) refusal raised by a plain bail!/anyhow! in
    project.rs, policy.rs, or source_add.rs prints its own message text on
    stdout/stderr, in both the default text format and --format json.
  • The --format json diagnostic's artifact/path/suggestedAction for
    such a refusal no longer claims a "runtime dependency" is unavailable.
  • A regression test exercises the full CLI path (not just the inner
    Result-returning function) for at least one authoring refusal, and asserts
    the refusal's own wording appears in the CLI's output.
  • products/casework/scripts/check-checkpoint.sh or an adjacent test
    confirms 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.
  • Existing tests in crates/registry-caseworkctl/src/lib.rs and
    crates/registry-caseworkctl/src/project.rs still pass; any test whose
    expected diagnostic text has to change is updated in the same change, with
    the reason noted in the PR.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:caseworkRegistry Casework runtime, tooling, and clientsbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions