Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
# OpenSpec Change: error-spec-tightening
- **Change ID**: error-spec-tightening
- **Package**: `@suaveplan/error`
- **Status**: PROPOSED
- **Date**: 2026-04-27
## Summary
Reconcile `openspec/specs/error/spec.md` with the actual `@suaveplan/error` implementation. Three real defects + several spec gaps:
1. **Spec ↔ impl runtime-dep violation** — spec mandates runtime deps = `{ tslib, zod }` and forbids any `@suaveplan/*`. Impl carries `@suaveplan/types` for `MessageResolver`. Resolution: amend the rule to allow `@suaveplan/types` exclusively (types-only, zero-runtime, byte-size guarded — same carve-out logic as testing-of-testing).
2. **`fromJSON` cannot satisfy spec scenario** — `SuaveplanError` is `abstract`, so `fromJSON` correctly returns a structurally-shaped POJO carrying the `__suaveplanError` discriminator. The spec scenario asserting `instanceof SuaveplanError` is wrong (impossible to satisfy by design). Resolution: rewrite the scenario around the discriminator + structural equality.
3. **Redaction was shallow** — `redactContext` only walked one level via `Object.entries`. Nested `Sensitive<T>` inside arrays / sub-objects passed through unredacted, leaking PII on the wire. **FIXED** in this change: `redactValue` now recursively walks objects and arrays with a cycle-safe `WeakSet`.
Plus three previously-undocumented behaviors that are pinned: `TruncatedCause` sentinel emitted by `toJSON` on cycle/depth-cap, `WIRE_VERSION` discriminator semantics, and `**Verified by:**` lines on every requirement.
## Current state
- `redactContext` (now `redactValue`, recursive) replaces `Sensitive<T>` with `REDACTED_LABEL` at any depth.
- `toJSON` emits `{ code: "ERROR.CAUSE.TRUNCATED", name: "TruncatedCause", message: "[cause chain truncated]", timestamp: 0 }` when `maxDepth` is exceeded or a cycle is detected — undocumented in spec.
- `fromJSON` returns `ReconstructedError` (POJO with `__suaveplanError: 1` discriminator). Cannot return a real `SuaveplanError` instance because the base class is abstract; reconstructing subclass identity from the wire is unsafe (consumer-defined classes aren't on the wire).
- `package.json` `dependencies` carries `@suaveplan/types` for the `MessageResolver` type. `@suaveplan/types` ships zero runtime code (verified by byte-size test in the types package).
## What changes
1. **Modify Requirement: Zero workspace and runtime dependencies** — narrow the prohibition: forbid `@suaveplan/*` packages EXCEPT `@suaveplan/types` (types-only, zero runtime). Same carve-out logic as the testing-infrastructure rule.
2. **Modify Requirement: Lossless wire serialization** — rewrite the JSON round-trip scenario around structural equality + `__suaveplanError` discriminator (drop the impossible `instanceof SuaveplanError` claim).
3. **Modify Requirement: Cause-chain traversal** — add a scenario covering cycle detection during `toJSON` (parallel to the existing `walkCauses` cycle scenario).
4. **Modify Requirement: Redaction at the serialization boundary** — codify recursive redaction: `Sensitive<T>` is replaced with `[REDACTED]` at any depth, in arrays and sub-objects, with cycle safety.
5. **ADD Requirement: TruncatedCause sentinel** — codify the sentinel shape and the conditions under which it is emitted (cycle OR depth-cap during `toJSON`).
6. **ADD Requirement: Wire format version discriminator** — codify `__suaveplanError: 1` as the wire version field, what `WIRE_VERSION` represents, and the forward-compat policy (rejects unknown versions via Zod).
7. **Add `src/conformance.spec-conformance.test.ts`** pinning recursive redaction, TruncatedCause emission, and the discriminator round-trip.
8. **Add `**Verified by:**` lines** to every requirement in the live spec.
## Why now
`@suaveplan/error` is the substrate every other `@suaveplan/*` package extends. Three of the issues here are real defects (PII leak via shallow redaction, an impossible spec assertion, and a self-violated dependency rule) — the others are drift that will silently regress under future refactoring without `**Verified by:**` lines pinning behavior.
## Impact
- `@suaveplan/error` — one behavioral fix (recursive redaction). Adds one `*.spec-conformance.test.ts`. Coverage stays at 100%.
- All downstream packages — none affected; `SuaveplanError` API surface unchanged.
- `@suaveplan/types` runtime dep is now contractually permitted (it was always present; spec just lied about it).
## Deferred (not in scope)
- `WIRE_VERSION` migration / negotiation policy beyond rejection of unknowns (can land when v2 is contemplated).
- Deprecation marker support in `defineCodes` (currently spec mandates removal-via-`@deprecated`, but impl has no support — defer; track separately).
- Per-subclass `ErrorContext` generic narrowing (would help downstream typing but is a bigger surface change).

View File

@@ -0,0 +1,139 @@
## 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/error` itself 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 = a` and `walkCauses(a)` is consumed
- **THEN** iteration yields `a` then `b` and 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 = a` and `toJSON(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 via `toJSON` and parsed back via `fromJSON`
- **THEN** the reconstructed value carries `__suaveplanError === 1` AND every wire field (`code`, `message`, `context` post-redaction, `timestamp`, `traceId`, `name`, `cause`) is structurally equal to the original AND the result is NOT `instanceof 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"` AND `cause 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")` and `toJSON(err)` runs
- **THEN** the serialized payload contains `context.token === "[REDACTED]"` (string)
#### Scenario: Nested sensitive value redacted
- **WHEN** `err.context.user = { profile: { token: markSensitive("abc") } }` and `toJSON(err)` runs
- **THEN** `result.context.user.profile.token === "[REDACTED]"`
#### Scenario: Sensitive value inside array redacted
- **WHEN** `err.context.tokens = [markSensitive("a"), markSensitive("b")]` and `toJSON(err)` runs
- **THEN** `result.context.tokens === ["[REDACTED]", "[REDACTED]"]`
#### Scenario: Cyclic context terminates redaction
- **WHEN** `err.context.self` is set to a cycle (`ctx.self = ctx`) and `toJSON(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 with `cause.cause.cause` of depth 3
- **THEN** the chain at the cap position contains the `TruncatedCause` sentinel AND `result.cause.cause.code === "ERROR.CAUSE.TRUNCATED"`
#### Scenario: Cycle emits sentinel
- **WHEN** `toJSON(a)` runs on `a.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 `toJSON` output containing a `TruncatedCause` sentinel is passed back through `fromJSON`
- **THEN** the reconstructed value carries the sentinel intact (`code === "ERROR.CAUSE.TRUNCATED"`, `name === "TruncatedCause"`) without throwing `WIRE.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 `toJSON` then `fromJSON`
- **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)

View File

@@ -0,0 +1,31 @@
# Tasks: error-spec-tightening
## 1. Code fix
- [x] 1.1 Replace one-level `redactContext` with recursive `redactValue` in `src/serialize/to-json.ts`. Walks objects + arrays, cycle-safe via `WeakSet`. Public `toJSON` signature unchanged.
## 2. Spec delta
- [ ] 2.1 Author `specs/error/spec.md` delta: `## MODIFIED Requirements` (Zero workspace and runtime dependencies, Lossless wire serialization, Cause-chain traversal, Redaction at the serialization boundary) + `## ADDED Requirements` (TruncatedCause sentinel, Wire format version discriminator).
- [ ] 2.2 Add `**Verified by:**` line to every requirement in the live spec.
## 3. Conformance test
- [ ] 3.1 Add `packages/core/error/src/conformance.spec-conformance.test.ts` pinning:
- Recursive redaction: `Sensitive<T>` inside nested objects + arrays becomes `"[REDACTED]"`.
- TruncatedCause sentinel emitted when `maxDepth` is exceeded.
- TruncatedCause sentinel emitted when a cycle is detected during `toJSON`.
- `__suaveplanError: 1` discriminator preserved through `toJSON` → `fromJSON`.
- Cycle-safety: redaction does not loop on circular context.
## 4. Gate
- [ ] 4.1 `bunx turbo lint typecheck test --filter=@suaveplan/error` green.
- [ ] 4.2 `bunx turbo test:coverage --filter=@suaveplan/error` 100/100/100/100.
- [ ] 4.3 `bun scripts/openspec-conformance.ts @suaveplan/error` exits 0.
## 5. Fold + archive
- [ ] 5.1 Fold delta into `openspec/specs/error/spec.md`.
- [ ] 5.2 Move `openspec/changes/error-spec-tightening/` to `openspec/archive/2026-04-27-error-spec-tightening/`.
- [ ] 5.3 Re-run gates after fold.