Skip to content

Commit f53bf00

Browse files
committed
feat(stack-encrypt)!: codes, help and fields on every error; dynamic input errors name the field
stack-encrypt was the one crate in the chain with no miette support: its errors were plain thiserror enums with no codes and no help, and the dynamic module's input errors were unit variants that could not say which field was wrong. A Go caller got "malformed input" for a bad plan, a record that did not fit it, an empty context and an over-long one alike. Every error type here now derives `miette::Diagnostic` with a `stack_encrypt::*` code, listed in `ERROR_CODES` and pinned by a test that builds every variant: `Error`, `PlanError`, `LabelError`, `LeafBytesError`, `sem::TermError`, `sem::TermBytesError`, and with `dynamic`, `dynamic::Error` and `dynamic::TargetError`. Help is added where a caller can act (`ForeignKeyset`, `DescriptorTooLong`, `EmptyTermText`, the plan refusals), and each implements the shared `ErrorPayload`: both keyset ids of a `ForeignKeyset`, the field and expected type of a `FieldType`, a descriptor's length against its limit. `Error::Kms`, `Term` and `Plan` forward the code of what they carry; `Kms` is now transparent outright, so a ZeroKMS keyset-not-found reads as itself. BREAKING CHANGE: `dynamic::Error::Context`, `Plan`, `Source` and `Record` are struct variants carrying `field: Option<String>` and a `Reason` from the new fixed `dynamic::Reason` enum (`MissingContext`, `DuplicateOutput`, `FieldMissing`, `NoCiphertextNode`, ...), and `Term` gains `field`. Every site that raised one now names the field it knows, plan builder and engine refusals included; `in_field` names it from the caller's side for functions that never see one. `Error::Kms` loses its message prefix. `ContextMismatch` gives the stored context's length and part count rather than its descriptor, which a context field can fill with customer data, and `TermError::Prf` and `MatchPositionOutOfRange` stop repeating another library's message and a value read from term bytes. The Go guest's status mapping matches the new shapes; its numbers are unchanged. Refs #1099 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf
1 parent f9b1eeb commit f53bf00

19 files changed

Lines changed: 2059 additions & 243 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎languages/golang/encrypt/guest/Cargo.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎languages/golang/encrypt/guest/src/status.rs‎

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,12 @@ pub fn status_for_error(error: &stack_encrypt::Error) -> u32 {
104104
pub fn status_for_dynamic(error: &stack_encrypt::dynamic::Error) -> u32 {
105105
use stack_encrypt::dynamic::Error;
106106
match error {
107-
Error::Context
107+
Error::Context { .. }
108108
| Error::Term { .. }
109-
| Error::Plan
109+
| Error::Plan { .. }
110110
| Error::UntypedIndex { .. }
111-
| Error::Source
112-
| Error::Record => STATUS_ENCODING,
111+
| Error::Source { .. }
112+
| Error::Record { .. } => STATUS_ENCODING,
113113
Error::Cipher(e) => status_for_error(e),
114114
// A target refusal is a statement about the plan, the label or the
115115
// value (an unknown or unproducible type, an extended plan, a value
@@ -405,26 +405,52 @@ mod tests {
405405

406406
#[test]
407407
fn dynamic_input_errors_are_encoding_and_a_library_bug_is_internal() {
408-
use stack_encrypt::dynamic::Error;
408+
use stack_encrypt::dynamic::{Error, Reason};
409409
use stack_encrypt::sem::MatchOptions;
410410
use stack_encrypt::target::IndexSpec;
411+
let age = || Some("age".to_string());
411412
for (label, err) in [
412-
("a bad context", Error::Context),
413+
(
414+
"a bad context",
415+
Error::Context {
416+
field: None,
417+
reason: Reason::EmptyContext,
418+
},
419+
),
413420
(
414421
"a bad term request",
415422
Error::Term {
423+
field: age(),
416424
kind: IndexSpec::Match(MatchOptions::default()),
417425
},
418426
),
419-
("a bad plan", Error::Plan),
427+
(
428+
"a bad plan",
429+
Error::Plan {
430+
field: age(),
431+
reason: Reason::DuplicateOutput,
432+
},
433+
),
420434
(
421435
"an indexed field with no type",
422436
Error::UntypedIndex {
423437
field: "age".to_string(),
424438
},
425439
),
426-
("a bad source", Error::Source),
427-
("a bad record", Error::Record),
440+
(
441+
"a bad source",
442+
Error::Source {
443+
field: age(),
444+
reason: Reason::FieldMissing,
445+
},
446+
),
447+
(
448+
"a bad record",
449+
Error::Record {
450+
field: age(),
451+
reason: Reason::NoCiphertextNode,
452+
},
453+
),
428454
] {
429455
assert_eq!(
430456
status_for_dynamic(&err),

‎packages/eql/Cargo.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/stack-encrypt/CHANGELOG.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Every error has a miette code, help where a caller can act, and its
13+
facts as structured fields.** `Error`, `PlanError`, `LabelError`,
14+
`LeafBytesError`, `sem::TermError`, `sem::TermBytesError` and, with
15+
`dynamic`, `dynamic::Error` and `dynamic::TargetError` derive
16+
`miette::Diagnostic` with a path-style code named after the crate
17+
(`stack_encrypt::aead`, `stack_encrypt::foreign_keyset`), listed in
18+
`stack_encrypt::ERROR_CODES` and pinned by a test that builds every
19+
variant. A variant that carries another crate's error forwards its code:
20+
`Error::Kms` shows `stack_kms::keyset_not_found` itself. Each error
21+
implements `ErrorPayload` (re-exported here from `stack-profile`, the
22+
crate `stack-profile`, `stack-auth`, `stack-kms` and this crate share),
23+
whose `payload()` gives the facts a caller branches on — both keyset ids
24+
of a `ForeignKeyset`, the field of a plan refusal — and whose docs hold
25+
the rule for what an error may contain: no plaintext, key material,
26+
tokens, ciphertext or term bytes, or raw context values. Codes are for
27+
crossing a boundary; Rust code keeps matching variants.
28+
- **A dynamic input error names its field and says why.** `dynamic::Reason`
29+
is the fixed vocabulary (`MissingContext`, `DuplicateOutput`,
30+
`FieldMissing`, `NoCiphertextNode`, ...; `as_str()` is its `snake_case`
31+
name), and `dynamic::Error::field()`, `reason()` and `in_field()` read
32+
and fill them.
1233
- **A data plan field may name an EQL type as its target.** Beside the
1334
output form, `dynamic::record::plan_with` reads `{"context": [...],
1435
"target": "TextEq", "type"?: ...}` — the two forms are exclusive — and
@@ -32,6 +53,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3253

3354
### Breaking
3455

56+
- **`dynamic::Error`'s input variants carry the field and a reason.**
57+
`Context`, `Plan`, `Source` and `Record` are struct variants
58+
`{ field: Option<String>, reason: Reason }`, and `Term` gains
59+
`field: Option<String>`: a match on `Error::Plan` becomes
60+
`Error::Plan { .. }`. The messages name both (`record plan is malformed:
61+
an output is named twice (field "age")`). A source with a field the plan
62+
does not name is now refused naming that field (`UnknownField`) after the
63+
plan's own fields are checked, rather than first, on a field count.
64+
- **`Error::Kms` is transparent.** Its message, code and help are the
65+
`stack_kms::Error`'s; the `ZeroKMS data-key operation failed:` prefix is
66+
gone, and `source()` skips to the ZeroKMS error's own cause.
67+
- **Some messages leave out what an error may not contain.**
68+
`Error::ContextMismatch` gives the stored context's length and number of
69+
parts instead of its descriptor, which can be customer data (the
70+
`stored` field still holds it). `sem::TermError::Prf` no longer repeats
71+
the PRF backend's message, and
72+
`sem::TermBytesError::MatchPositionOutOfRange` no longer quotes the
73+
position read from the term's bytes: both stay on the error for a caller
74+
in this process.
3575
- **A data plan field with a term output must declare its `"type"`.** A
3676
plan whose indexed field (`"eq"`, `"match"`, `"ore"`, `"ope"`) has no
3777
`"type"` is refused when it is built (`Error::UntypedIndex`, naming the

‎packages/stack-encrypt/Cargo.toml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ serde = { workspace = true }
4141
base64ct = { version = "1.7", features = ["alloc"] }
4242

4343
cllw-ore = { workspace = true }
44+
# Every error derives `miette::Diagnostic` (a code, and help where a caller
45+
# can act) and gives its structured fields as a `serde_json` map through
46+
# `ErrorPayload`. Both are already in the graph through stack-kms.
47+
miette = { workspace = true }
48+
serde_json = { workspace = true }
4449
thiserror = { workspace = true }
4550
uuid = { workspace = true }
4651
zeroize = { workspace = true }
@@ -63,7 +68,6 @@ dynamic = ["dep:vitaminc-aead-value"]
6368
test-support = ["stack-kms/test-support"]
6469

6570
[dev-dependencies]
66-
serde_json = { workspace = true }
6771
# The deterministic key source the record fixture is sealed under
6872
# (`tests/common`): keys and tags derived from a seed, the descriptor and the
6973
# IV, so a committed record opens in another process. Same version stack-kms

‎packages/stack-encrypt/fuzz/Cargo.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)