BE-4067 error DX clarity

A second body of work under BE-4067, on its own branch feature/BE-4067-grammar (stacked on the unwalling branch). It makes the #[mando_error] surface self-explanatory and hard to misuse, then extends the error system down into mando-bess-lib. Two phases: Phase 1 = an emission-NEUTRAL grammar rename (#[cause] #[reframe]/#[chain]/#[transparent] + reflexive lift + opaque MandoReport + flatten_report ban), Phase 2 = a SEMANTIC adoption of mando-bess-lib’s 25 service enums into the error system plus a deliberate DD telemetry change on the financial flows.

For Agents

Branch feature/BE-4067-grammar @ 0a3c2382, stacked on feature/BE-4067 @ 9038b0c6 (the unwalling tip / golden-baseline anchor). 10 commits, LOCAL ONLY, NOT pushed. Fully implemented + gate-verified. PENDING: (1) Andras’s telemetry sign-off on the Phase-2 before/after delta; (2) final whole-branch review; (3) push + MR - each a separate explicit yes. Spec: docs/superpowers/specs/2026-08-06-error-attribute-grammar-clarity-design.md; plan: docs/superpowers/plans/2026-08-06-error-dx-clarity.md; handover: docs/superpowers/HANDOVER-error-dx-2026-08-10.md (all untracked).

The new #[mando_error] grammar (the most reusable takeaway)

#[cause(X)] / #[cause(wraps(X, message))] / #[error(transparent)] #[from] are replaced by three self-naming verb attributes. The developer writes zero impls; the macro (mando-macros/src/mando_report.rs) generates all MandoFrom / MandoWraps / transparent-Display. Model file: mando-lib/src/adapter/mando/microsoft.rs.

RoleNew attributeVariant shapePropagates withBehavior
reframe (was #[cause(Src)])#[reframe(Src)] / #[reframe(A, B)]{ message: String }bare ?flatten a FOREIGN error into your worded message; source TYPE dropped, only its .to_string() survives inside your #[error("...")]
chain (was #[cause(wraps(...))])#[chain(Inner)] (unit) / #[chain(Inner, "msg")] (message variant)unit (preferred) or { message }.wrapped()?wrap a MANDO error, adding a frame; the inner report is KEPT underneath
transparent (was #[error(transparent)] #[from])#[transparent(Inner)]newtype (Inner)bare ?re-expose a sub-error unchanged; inner’s Display shows through your type-label

Two structural rules that fall out of this:

  • #[transparent(Inner)] is ONE self-naming line: the macro STRIPS the attribute and INJECTS thiserror’s #[error(transparent)] + #[from] into the derive input, plus a generated MandoFrom<Inner> (so it also ?s into a MandoResult, unlike the old #[from]). Writing #[error(transparent)] yourself inside a #[mando_error] enum is now a hard macro error (scoped to #[mando_error] enums only - plain-thiserror elsewhere stays legal).
  • The plain #[from]-on-a-named-source form (e.g. ReqwestClientError { #[from] source: reqwest::Error }) is a fifth shape left untouched - the transparent ban does not reach it.

”Which attribute do I use” (the mental model - headline the docs with this)

The field shape tells you the behavior with zero prior knowledge:

  • { message: String } reframe - it’s your text now.
  • unit / no stored error chain - a frame added, source kept underneath.
  • (SomeError) transparent - the source, shown through your label.

One-liner: reframe replaces the error with your message; chain adds your message on top of it; transparent lets it show through unchanged.

Rule of thumb: a foreign library error you don’t want leaking (reqwest/polars/aws) reframe; your own lower mando error crossing a layer boundary chain; your own sub-error re-exposed in a union enum transparent.

The hard pair - reframe vs transparent

Both pull a source error into your enum; the ONLY difference is what happens to the source. reframe turns it into TEXT (your words); transparent keeps it as an ERROR (its words). Three redundant tells: (1) field { message } vs (SomeError); (2) reframe HAS an #[error("...")], transparent has NONE; (3) the verbs - transparent = look through it, reframe = new frame around it.

Pillar 2: “Result suffices” (reflexive MandoFrom<Self>)

The macro now generates a reflexive impl MandoFrom<Self> per #[mando_error] enum (e.into_report()). Combined with the blanket From<S> for MandoReport<E>, a bare Result<T, E> auto-lifts into a MandoResult<T, E> on a plain ? - .reported() becomes unnecessary for the common case and was dropped from ~35 production sites. .reported()? still works where the enum is already the error type; #[reframe(Self)] is nonsensical and rejected.

The guardrail (this is NOT "abolish MandoResult")

The frame is created AT the ? via into_report, so a fn returning bare Result hands out an un-framed error. Use bare Result for leaves; boundaries that add context (.attr / .chain) still return MandoResult so the frame chain accumulates as it climbs. “Boundaries return MandoResult” is a deferred CI/dylint lint, not machine-enforced here - reviewers must watch for bare-Result returns on context-adding boundaries.

Pillar 3: no silent stack loss (opaque MandoReport + flatten_report ban)

Two holes closed so you cannot silently downgrade a MandoReport<E> back to a bare E (dropping the frame chain):

  1. Deref for MandoReport DROPPED (mando-core/src/report.rs). Inner access is now only via the explicit as_report() / into_inner(). Zero production breaks; ~30 TEST-code fixups across 9 files (fixed with the sanctioned .unwrap_err().into_inner() idiom, never the banned downgrade).
  2. flatten_report clippy-banned via disallowed_methods, given workspace-wide teeth in Phase 2 (see gotcha below). Terminal reads get a narrow #[allow].

current_context().clone() is NOT clippy-guardable

disallowed_methods matches a single method by path with no “followed by .clone()” pattern, and banning Report::current_context would over-flag every legitimate READ. That downgrade is review-enforced (backed by the Deref removal, which forces the verbose/greppable report.as_report().current_context().clone()) - do NOT claim clippy guards it. The StackBoundary capability-token option was deliberately NOT taken.

Phase 2: mando-bess-lib error-system adoption (SEMANTIC / telemetry change)

The grounding correction that drove this: the spec’s first draft miscounted “72 transparent in mando-bess-lib” as in-scope for the Phase-1 rename - they were PLAIN thiserror, never in the error system. Andras chose to ADOPT them. 25 #[derive(Error, Debug)] service enums in mando-bess-lib/src/service/** are now #[mando_error].

  • Level A (600937bc, provably neutral): mark #[mando_error], all 72 foreign-inner newtypes #[transparent(Inner)], boundaries unchanged. DD byte-identical on its own.
  • Level B (8b9a2ea7, the telemetry change): the ~21 step boundaries switched from ErrorWithStepStatus::new(status, self) to a NEW shared helper ErrorWithStepStatus::reported(status, error) / from_report(status, report) (added to mando-lib/src/workflow/mod.rs; additive - ems.rs keeps new().with_report()). The 3 in-system inners (OptimizationParamsError, ForecastParamsError) reshaped V(Inner) V{message} + #[chain] (variant NAME preserved so error.code is unchanged); MicrosoftClientError + AfrrPositionSenderError kept #[transparent] (bare-error / constructor-fn sites).
  • P2.3 (0a3c2382): enabled disallowed_methods = { level="deny", priority=-1 } in root Cargo.toml [workspace.lints.clippy], overriding all = "allow" at -2 and giving the flatten_report + Report::new bans workspace-wide teeth.

The TELEMETRY DELTA on financial flows (idc_order, auction) - AWAITING SIGN-OFF

error.code / kind / type: UNCHANGED (outermost variant full-path). error.message: source changes (anyhow root_cause report innermost) but text usually identical. error.stack: format CHANGES (colon-joined chain render_stack_tree nested tree). error.errors: ADDED (frame-code array). error.details: ADDED (per-frame code/file/line/message/attributes). Previously-discarded in-system inner chains now SURFACE as a 2nd frame. Captured as committed INLINE dd_emission asserts in .../intraday/idc_order.rs (chosen over golden FILES so the mando-lib Phase-1 goldens stay byte-identical). mando-lib adapter emission is UNCHANGED.

Gotchas worth remembering

Workspace all = "allow" silently disables disallowed_methods

Root Cargo.toml sets [workspace.lints.clippy] all = { level="allow", priority=-2 }, which turns OFF disallowed_methods (part of clippy::all) in every crate inheriting [lints] workspace = true. So the pre-existing Report::new / tracing::error bans (and the new flatten_report ban) only fired in mando-core until P2.3 added disallowed_methods = "deny" at higher priority.

py_mando "could not compile (lib)" on a full build is NOT a compile error

It is the known macOS cdylib LINK failure (__Py_TrueStruct / interpreter-less link). cargo check -p py_mando is CLEAN - it compiles, only the link fails; CI covers it. Same for py_mando_simulation. Do not panic-diagnose it. (Full cargo test --all-features is likewise not runnable locally.)

cargo check skips #[cfg(test)]

The Deref-removal test-fixup estimate under-counted (10 ~30) because cargo check does not compile test modules. Use cargo test --no-run / a full build to size test-code impact.

The #[chain] reshape drops the transparent #[from]

Reshaping V(Inner) V{message} + #[chain] removes the auto-#[from], which breaks bare ? at OTHER construction sites (bit configuration.rs in intraday/auction/manual). Grep for ALL construction sites of a variant before reshaping it; fix with explicit .map_err(|e| ...{ message: e.to_string() })?.

Whole-file rustfmt reflows legacy lines on this tree

This is a non-fmt-clean tree; whole-file rustfmt (let-else joins, field-chain rewraps) = theme-1 churn (reverted twice in Phase 2). Format only your own changed lines by hand.

Gate status (green on 0a3c2382)

  • Exhaustiveness: 0 #[cause( attrs, 0 #[error(transparent)] inside #[mando_error], 0 #[derive(Error in mando-bess-lib, 0 clippy disallowed-method violations.
  • clippy --release --all-features: clean.
  • -p mando_lib --lib --features app,test-util: 379 passed / 0 failed.
  • -p mando_bess_lib: 10 passed / 0 failed (incl. 3 new dd_emission tests).
  • mando-lib DD goldens byte-identical vs 9038b0c6 (the load-bearing neutrality proof for all of Phase 1).

The 10 commits (9038b0c6..0a3c2382)

Phase 1: 84b0b8dd add reframe/chain/transparent + reflexive lift; 317ceeb6 rename causereframe/chain in mando-core+mando-lib; 208caf39 convert volue ems transparent variants; 0f07280e drop cause grammar + reject error(transparent); 10e83e59 drop Deref on MandoReport; 72ccc160 ban flatten_report (mando-core-scoped); 1cca874f docs to new grammar. Phase 2: 600937bc adopt #[mando_error] in mando-bess-lib (Level A); 8b9a2ea7 switch boundaries to rich report emission (Level B, telemetry change); 0a3c2382 enable workspace disallowed_methods + resolve flatten_report sites.

North-star context

graph LR
    A["BE-3657<br/>error_stack adoption"] --> B["BE-4000<br/>#[mando_error] derive"]
    B --> C["BE-4047<br/>unwalling begun"]
    C --> D["BE-4067<br/>unwalling FINISHED"]
    D --> E["<b>BE-4067 grammar</b><br/>reframe/chain/transparent<br/>+ bess-lib adoption"]
    E --> F["still open:<br/>trace conformance,<br/>downstream services,<br/>phase 4 #[error_meta],<br/>merge the 25 bess-lib enums"]
    style E fill:#264653,stroke:#2a9d8f,color:#fff
    style F fill:#3d2020,stroke:#a55,color:#fff

Phase 2 ADOPTED the ~25 mando-bess-lib service enums into the error system but did not merge/dedupe them - that consolidation (“merging”) remains a separate future ticket, alongside mando trace/instrumentation conformance, error+trace conformance in the downstream services, Phase 4 #[error_meta], and the unlanded error.trace field + .step_context() helper.