8.9 KiB
MODIFIED Requirements
Requirement: Zero workspace and runtime dependencies
The @suaveplan/error package's dependencies MUST be limited to tslib, zod, and @suaveplan/types. No peerDependencies. No other @suaveplan/* packages at runtime. The @suaveplan/types carve-out is permitted exclusively because it ships zero runtime code (types-only build, byte-size guarded by its own conformance test) and therefore cannot reintroduce the circular foundation-of-foundations hazard the rule otherwise prevents.
Verified by: tests/no-disallowed.test.ts and the package.json dep-set under bunx turbo ls --filter=@suaveplan/error....
Scenario: Dep-graph guard
- WHEN
bunx turbo ls --filter=@suaveplan/error...is run - THEN the only
@suaveplan/*packages in the forward closure are@suaveplan/erroritself and@suaveplan/types
Scenario: No Node / browser built-ins
- WHEN the built
dist/is grep'd - THEN it MUST NOT reference
node:fs,node:path,node:url,process,Buffer,document,window, or any DOM / Node-only API
Requirement: Cause-chain traversal is cycle-safe and depth-bounded
The package SHALL export walkCauses(err, { maxDepth?: number }) as a generator yielding each error in the chain. Default maxDepth is 32. Cycles MUST be detected via a WeakSet and terminate iteration without throwing. The same cycle-and-depth discipline SHALL apply to toJSON serialization: when traversal encounters a cycle OR exceeds maxDepth, the cause field is replaced with the TruncatedCause sentinel (see "TruncatedCause sentinel" requirement) rather than throwing or looping.
Verified by: src/causes/walk-causes.test.ts and src/conformance.spec-conformance.test.ts (toJSON cycle + depth-cap scenarios).
Scenario: Linear chain fully traversed
- WHEN
a.cause = b; b.cause = c; c.cause = d - THEN
Array.from(walkCauses(a))returns[a, b, c, d]
Scenario: Cycle terminates iteration
- WHEN
a.cause = b; b.cause = aandwalkCauses(a)is consumed - THEN iteration yields
athenband ends (does not throw, does not loop forever)
Scenario: Depth cap enforced
- WHEN a chain of 100 errors is walked with
{ maxDepth: 10 } - THEN exactly 10 errors are yielded
Scenario: toJSON cycle yields TruncatedCause sentinel
- WHEN
a.cause = b; b.cause = aandtoJSON(a)runs - THEN
result.cause.cause.code === "ERROR.CAUSE.TRUNCATED"AND iteration does not loop forever
Requirement: Lossless wire serialization
The package SHALL export toJSON(err) and fromJSON(value) producing a Zod-validated wire payload. fromJSON returns a ReconstructedError POJO carrying the __suaveplanError: 1 discriminator — NOT a real SuaveplanError instance, since the base class is abstract and consumer-defined subclasses are not on the wire. Round-tripping a SuaveplanError subclass through JSON MUST preserve code, message, context (redactions applied), timestamp, traceId, name, and the cause chain (up to maxDepth). The binary form (toBinary / fromBinary) is a deterministic UTF-8 encoding of the canonicalised JSON form (no external encoder dep); both halves of a round-trip produce structurally identical output.
Verified by: src/serialize/serialize.test.ts and src/conformance.spec-conformance.test.ts (discriminator round-trip scenario).
Scenario: JSON round-trip preserves structural fields and discriminator
- WHEN an error with
{ code, message, context, cause }is serialized viatoJSONand parsed back viafromJSON - THEN the reconstructed value carries
__suaveplanError === 1AND every wire field (code,message,contextpost-redaction,timestamp,traceId,name,cause) is structurally equal to the original AND the result is NOTinstanceof SuaveplanError(intentionally — base class is abstract)
Scenario: Binary round-trip matches JSON round-trip
- WHEN the same error is passed through
toBinary/fromBinary - THEN the resulting reconstructed value is structurally equal to the JSON round-trip output
Scenario: Zod rejects malformed wire payload
- WHEN
fromJSON({ code: 123 })is called (wrong type) - THEN it throws an error with
code === "ERROR.WIRE.MALFORMED"ANDcause instanceof ZodError
Requirement: Redaction at the serialization boundary
The package SHALL export markSensitive(value) producing a tagged wrapper. During toJSON / toBinary, tagged values MUST be replaced with the literal string "[REDACTED]" AT ANY DEPTH within err.context — including values nested inside arrays and sub-objects. The redaction walk MUST be cycle-safe (a circular context graph terminates without throwing or looping). In-memory access to the original value via err.context is UNAFFECTED.
Verified by: src/redact/sensitive.test.ts, src/serialize/serialize.test.ts, and src/conformance.spec-conformance.test.ts (deep + cycle-safe redaction scenarios).
Scenario: Sensitive field redacted on the wire
- WHEN
err.context.token = markSensitive("abc")andtoJSON(err)runs - THEN the serialized payload contains
context.token === "[REDACTED]"(string)
Scenario: Nested sensitive value redacted
- WHEN
err.context.user = { profile: { token: markSensitive("abc") } }andtoJSON(err)runs - THEN
result.context.user.profile.token === "[REDACTED]"
Scenario: Sensitive value inside array redacted
- WHEN
err.context.tokens = [markSensitive("a"), markSensitive("b")]andtoJSON(err)runs - THEN
result.context.tokens === ["[REDACTED]", "[REDACTED]"]
Scenario: Cyclic context terminates redaction
- WHEN
err.context.selfis set to a cycle (ctx.self = ctx) andtoJSON(err)runs - THEN redaction completes without throwing or looping (cycle short-circuits via
WeakSet)
Scenario: In-memory value remains accessible
- WHEN
err.context.token = markSensitive("abc")(unserialized) - THEN
unwrapSensitive(err.context.token) === "abc"
ADDED Requirements
Requirement: TruncatedCause sentinel
When toJSON encounters either (a) a cause-chain depth that exceeds maxDepth OR (b) an already-seen object (cycle), the cause field SHALL be replaced with a sentinel WireError of shape { __suaveplanError: 1, code: "ERROR.CAUSE.TRUNCATED", name: "TruncatedCause", message: "[cause chain truncated]", timestamp: 0 }. The sentinel is structurally a valid WireError (passes Zod validation) and is therefore safely consumable by fromJSON downstream. The sentinel MUST NOT introduce a real Error throw; truncation is a non-fatal terminal event in the chain.
Verified by: src/conformance.spec-conformance.test.ts (depth + cycle sentinel scenarios) and src/serialize/serialize.test.ts.
Scenario: Depth-cap emits sentinel
- WHEN
toJSON(err, { maxDepth: 1 })runs on an error withcause.cause.causeof depth 3 - THEN the chain at the cap position contains the
TruncatedCausesentinel ANDresult.cause.cause.code === "ERROR.CAUSE.TRUNCATED"
Scenario: Cycle emits sentinel
- WHEN
toJSON(a)runs ona.cause = b; b.cause = a(cycle) - THEN the chain terminates at the cycle with the sentinel AND no infinite loop occurs
Scenario: Sentinel survives round-trip
- WHEN a
toJSONoutput containing aTruncatedCausesentinel is passed back throughfromJSON - THEN the reconstructed value carries the sentinel intact (
code === "ERROR.CAUSE.TRUNCATED",name === "TruncatedCause") without throwingWIRE.MALFORMED
Requirement: Wire format version discriminator
The wire payload SHALL carry a __suaveplanError: 1 discriminator literal validated by Zod's z.literal(1). This identifies the payload as a @suaveplan/error wire envelope and pins the format version. WIRE_VERSION is the single source-of-truth constant in src/constants/index.ts. fromJSON MUST reject payloads where __suaveplanError is missing OR not equal to WIRE_VERSION with code === "ERROR.WIRE.MALFORMED" (Zod-driven). When WIRE_VERSION is bumped (future), payload migration is the consumer's responsibility — there is no implicit version negotiation in this package.
Verified by: src/serialize/wire.schemas.ts (Zod literal), src/serialize/serialize.test.ts, and src/conformance.spec-conformance.test.ts (discriminator scenarios).
Scenario: Discriminator preserved through round-trip
- WHEN an error is passed through
toJSONthenfromJSON - THEN the reconstructed value has
__suaveplanError === 1
Scenario: Missing discriminator rejected
- WHEN
fromJSON({ code: "X.Y.Z", message: "m", timestamp: 0, name: "E" })runs (no__suaveplanError) - THEN it throws with
code === "ERROR.WIRE.MALFORMED"
Scenario: Wrong discriminator rejected
- WHEN
fromJSON({ __suaveplanError: 2, ...rest })runs - THEN it throws with
code === "ERROR.WIRE.MALFORMED"(Zod literal mismatch)