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 onfeature/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.
| Role | New attribute | Variant shape | Propagates with | Behavior |
|---|---|---|---|---|
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 generatedMandoFrom<Inner>(so it also?s into aMandoResult, 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
?viainto_report, so a fn returning bareResulthands out an un-framed error. Use bareResultfor leaves; boundaries that add context (.attr/.chain) still returnMandoResultso 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-Resultreturns 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):
Deref for MandoReportDROPPED (mando-core/src/report.rs). Inner access is now only via the explicitas_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).flatten_reportclippy-banned viadisallowed_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_methodsmatches a single method by path with no “followed by.clone()” pattern, and banningReport::current_contextwould over-flag every legitimate READ. That downgrade is review-enforced (backed by theDerefremoval, which forces the verbose/greppablereport.as_report().current_context().clone()) - do NOT claim clippy guards it. TheStackBoundarycapability-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 fromErrorWithStepStatus::new(status, self)to a NEW shared helperErrorWithStepStatus::reported(status, error)/from_report(status, report)(added tomando-lib/src/workflow/mod.rs; additive - ems.rs keepsnew().with_report()). The 3 in-system inners (OptimizationParamsError,ForecastParamsError) reshapedV(Inner)→V{message}+#[chain](variant NAME preserved soerror.codeis unchanged);MicrosoftClientError+AfrrPositionSenderErrorkept#[transparent](bare-error / constructor-fn sites). - P2.3 (
0a3c2382): enableddisallowed_methods = { level="deny", priority=-1 }in rootCargo.toml [workspace.lints.clippy], overridingall = "allow"at-2and giving theflatten_report+Report::newbans 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_treenested 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 INLINEdd_emissionasserts 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 disablesdisallowed_methodsRoot
Cargo.tomlsets[workspace.lints.clippy] all = { level="allow", priority=-2 }, which turns OFFdisallowed_methods(part ofclippy::all) in every crate inheriting[lints] workspace = true. So the pre-existingReport::new/tracing::errorbans (and the newflatten_reportban) only fired in mando-core until P2.3 addeddisallowed_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_mandois CLEAN - it compiles, only the link fails; CI covers it. Same for py_mando_simulation. Do not panic-diagnose it. (Fullcargo test --all-featuresis likewise not runnable locally.)
cargo checkskips#[cfg(test)]The Deref-removal test-fixup estimate under-counted (10 → ~30) because
cargo checkdoes not compile test modules. Usecargo 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(Errorin 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 newdd_emissiontests).- 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 cause→reframe/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.
Related
- BE-4067 whole-mando error unwalling - the branch this is stacked on; this grammar work reshapes the
#[cause]idiom that unwalling adopted everywhere - BE-4000 derive transplant - the
#[mando_error]/#[cause(...)]/ bare-?devex idiom whose grammar is renamed here - BE-4047 - where the unwalling began
- BE-3657 error_stack Adoption - master error-stack trail; the “walled towers” lineage
- BE-4014 Datadog Error-Rendering Test Harness - the DD golden harness; the mando-lib goldens staying byte-identical is Phase 1’s neutrality proof, and its capture helpers back the Phase-2
dd_emissionasserts - BE-3541 Single Error Emission - the single-boundary-emission direction that the report-path boundary switch continues
- Agent Context