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,99 @@
# Serialize proto-guard + structured-clone surface
**Package**: `@suaveplan/serialize`
**Status**: PROPOSED
**Type**: feature + spec-conformance
## Why
Two pieces of upstream `@suaveplan/serialize` did not migrate cleanly into genesis: the `security/proto-guard` prototype-pollution stripper and the `codec/structured-clone` deep-clone wrapper. The 2026-05-02 audit found:
1. The genesis JSON decoder applies prototype-pollution protection by raising on dangerous keys, but it does so via the JSON `reviver` only — there is no public sanitizer that downstream consumers can apply to *already-decoded* values. Consumers receiving an `unknown` from MessagePack, from a custom binary format, or from a pre-existing in-memory object cannot today say "strip `__proto__` / `constructor` / `prototype` keys recursively." Upstream's `stripProtoKeys(value, allow)` (`packages/universal/serialize/src/security/proto-guard.ts` L1-L30) covers exactly this case and is referenced by `slsa`, `audit-logging`, `etl`, and the network plugin protocol packages — all genesis dependents already shipping.
2. The genesis `codec/` directory has no `structured-clone` module. Upstream ships `deepClone<T>(value: T): T` as a thin platform-`structuredClone` wrapper that throws a structured `SerializationError` when the runtime lacks support, and re-exports it from the root `index.ts`. Genesis lost both the helper and the re-export — the only `structuredClone` reference left is a benchmark file. Consumers that need to defensively copy decoded payloads are forced to call `globalThis.structuredClone` directly, losing the structured-error contract and runtime-availability guard.
Both gaps are dependent on the existing serialize public surface; both are small enough to ship together as one coordinated patch. Doing so closes the loop on "every decoded value can be hardened against prototype pollution AND defensively cloned through one package" without re-opening the eval-safe / `/unsafe` subpath split, which already covers the policy boundary.
## What changes
### `@suaveplan/serialize`
- ADDED: `src/security/proto-guard/proto-guard.ts` — `stripPrototypePolluters(value: unknown, options?: { allowDangerousKeys?: boolean }): void` recursively removes `__proto__`, `constructor`, and `prototype` keys from any plain-object or array node in the tree (no traversal into class instances, Maps, Sets, Dates, Uint8Arrays, etc.). Mutates in place.
- ADDED: `src/security/proto-guard/proto-guard.ts` exports `cloneAndStrip<T>(value: T, options?: { allowDangerousKeys?: boolean }): T` — non-mutating variant; deep-clones via `deepClone` then strips.
- ADDED: `src/codec/structured-clone/structured-clone.ts` — `deepClone<T>(value: T): T`; throws `SerializationError` (`STRUCTURED_CLONE.UNAVAILABLE`) when `globalThis.structuredClone` is missing (older Node, restricted runtimes).
- ADDED: `src/codec/structured-clone/index.ts` barrel re-exporting `deepClone`.
- ADDED: subpath export `./security/proto-guard` and `./codec/structured-clone` in `package.json::exports`.
- ADDED: error codes `STRUCTURED_CLONE.UNAVAILABLE`, `PROTO_GUARD.FORBIDDEN_KEY` registered in `src/error-codes.ts`.
- MODIFIED: `src/index.ts` re-exports `deepClone` and `stripPrototypePolluters` from the root barrel for convenience (matches upstream root-export shape).
- MODIFIED: `src/codec/json/json-decode.ts` — when `options.stripPrototypePolluters === true`, the decoder calls `stripPrototypePolluters(parsed)` after the existing reviver pass. Default: `false` (the reviver-only path remains the documented eval-safe default).
- ADDED: OTel span event `serialize.proto-guard.strip` annotated with `keysRemoved: number` for observability.
## Out of scope
- **Sandbox-policy enforcement.** The eval-safe vs `./unsafe` subpath split already covers the policy boundary for what code paths can call `eval`/`Function`. This change does not touch that split.
- **Cross-realm cloning.** `deepClone` defers to `globalThis.structuredClone` and inherits whatever cross-realm behaviour the platform provides. No iframe / Worker / Cross-Origin Isolated handling.
- **Frozen-object preservation.** Upstream's `proto-guard` mutates in place; the cloned variant via `cloneAndStrip` is the documented escape hatch. Adding "produce a `Object.freeze`'d output" is a separate proposal if ever requested.
- **MessagePack proto-guard hook.** The MessagePack decoder does not currently expose a `stripPrototypePolluters` flag. Adding one is trivial but extends the public surface; deferred to a follow-up if a consumer asks. JSON gets the flag because that's where prototype pollution is most exploitable.
- **Replacing the JSON reviver guard.** The reviver-based check stays — it's a stricter pre-build guard and rejects malformed input *before* an object is ever constructed. `stripPrototypePolluters` is a *post-build* sanitizer for decoded-elsewhere values.
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `stripProtoKeys(value, allow)` | ✅ | ⬆️ | Renamed to `stripPrototypePolluters` (clearer name); takes `{ allowDangerousKeys?: boolean }` options object instead of positional `boolean`. |
| `cloneAndStrip` | 🚫 (not in upstream) | ⬆️ | Added: non-mutating variant for callers that must preserve the input. |
| `deepClone(value)` | ✅ | ✅ | Verbatim port; same `SerializationError` on missing `globalThis.structuredClone`. |
| Root-barrel re-export of `deepClone` | ✅ | ✅ | Restored. |
| Root-barrel re-export of `stripPrototypePolluters` | 🚫 (not in upstream) | ⬆️ | Added: matches the discoverability of `serialize` / `deserialize`. |
| OTel span event for proto-guard | 🚫 (not in upstream) | ⬆️ | Added per genesis-wide observability rule. |
| JSON decoder `stripPrototypePolluters` option | 🚫 (not in upstream) | ⬆️ | Added: opt-in post-pass; default off keeps the existing eval-safe contract. |
## Source mapping
This change derives from `../suaveplan/packages/universal/serialize/src/security/proto-guard.ts` and `../suaveplan/packages/universal/serialize/src/codec/structured-clone/structured-clone.ts`.
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| `stripPrototypePolluters` (recursive, in-place, plain-object/array only) | `packages/universal/serialize/src/security/proto-guard.ts` | L1-L30 | Verbatim port; renamed function; `boolean` second arg → `{ allowDangerousKeys }` options. |
| `deepClone` over `globalThis.structuredClone` with structured error fallback | `packages/universal/serialize/src/codec/structured-clone/structured-clone.ts` | L1-L18 | Verbatim port; `SerializationError` instead of legacy class. |
| Root-barrel re-export of `deepClone` | `packages/universal/serialize/src/index.ts` | L17 (line listing `deepClone` in the export block) | Restore the export. |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `cloneAndStrip` non-mutating variant | Mutating decoded payloads breaks immutable-state consumers (`@suaveplan/event-sourcing`). Upstream sidestepped this by always recommending callers clone first, but bundling clone+strip into one helper is a clearer ergonomic. |
| OTel span event `serialize.proto-guard.strip` with `keysRemoved` count | Genesis-wide observability rule — every public production code path emits passive OTel data. Upstream had no tracing here. |
| Error codes `STRUCTURED_CLONE.UNAVAILABLE`, `PROTO_GUARD.FORBIDDEN_KEY` | Genesis-wide policy that every thrown error route through `@suaveplan/error` with a registered stable code. Upstream throws ad-hoc `SerializationError` instances; we register codes so consumers can `instanceof` + code-check. |
| Subpath exports `./security/proto-guard` and `./codec/structured-clone` | Genesis-wide rule (AGENTS.md §6) — every feature module gets a tree-shakeable subpath. Upstream re-exported only via the root barrel. |
| JSON decoder `stripPrototypePolluters` option | Genesis-wide eval-safe contract — the option must be explicit, opt-in, and observable; default behaviour does not change. |
## Verification approach
- `src/security/proto-guard/proto-guard.test.ts` — recursion across plain objects and arrays; non-traversal into class instances / Map / Set / Date / Uint8Array; in-place mutation; `cloneAndStrip` preserves the original.
- `src/security/proto-guard/proto-guard.spec-conformance.test.ts` — `allowDangerousKeys: true` no-ops; OTel span event emitted with `keysRemoved` count via real in-memory `MeterProvider` / `TracerProvider`.
- `src/codec/structured-clone/structured-clone.test.ts` — round-trip for primitives, objects, arrays, nested structures, Map, Set, Date, Uint8Array; `deepClone(undefined)` returns `undefined`.
- `src/codec/structured-clone/structured-clone.spec-conformance.test.ts` — `SerializationError` with `STRUCTURED_CLONE.UNAVAILABLE` thrown when `globalThis.structuredClone` is replaced with `undefined`; subpath import resolves.
- `src/codec/json/json-decode.spec-conformance.test.ts` — new scenario asserting `stripPrototypePolluters: true` runs the post-pass (count of removed keys observed via OTel span event).
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/serialize` to enumerate:
- `@suaveplan/network-plugin-protocol` — [ ] tests run / [ ] docs reviewed / [x] no action needed (consumes only `serialize` / `deserialize` / `decodeBinary` / `encodeBinary`; new exports are additive)
- `@suaveplan/network-websockets` — [ ] tests run / [ ] docs reviewed / [x] no action needed (same as above)
- `@suaveplan/network-plugin-webrtc` — [ ] tests run / [ ] docs reviewed / [x] no action needed (same as above)
- `@suaveplan/slsa` — [x] tests run / [x] docs reviewed / [ ] no action needed (decoded provenance values benefit from `stripPrototypePolluters`; doc recommends it but does not require it)
- `@suaveplan/audit-logging` — [x] tests run / [x] docs reviewed / [ ] no action needed (same as slsa — recommend `stripPrototypePolluters` on imported audit log batches)
- `@suaveplan/etl` — [x] tests run / [x] docs reviewed / [ ] no action needed (decoded source records benefit from `stripPrototypePolluters` before transformation; doc recommends it)
`@suaveplan/serialize` is **not** on the foundation-package list (`error`, `types`, `events`, `logger`, `validation`), but it is depended on by ≥4 downstream packages so the dependent sweep (§13 step 10) is run anyway:
```bash
bunx turbo test --filter=...@suaveplan/serialize
```
## Migration impact
None — purely additive. The JSON decoder gains an opt-in option whose default is `false`, so existing call sites are unaffected. The root barrel gains two new exports (`deepClone`, `stripPrototypePolluters`) — name collisions with existing consumer code would have surfaced already and none have been reported.

View File

@@ -0,0 +1,108 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: serialize (serialize-proto-guard)
**Package**: `@suaveplan/serialize`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: Prototype-pollution stripper
The package SHALL expose `stripPrototypePolluters(value: unknown, options?: { allowDangerousKeys?: boolean }): void` that recursively removes the `__proto__`, `constructor`, and `prototype` keys from every plain-object and array node it encounters in `value`. The function SHALL mutate the input tree in place. Class instances, `Map`, `Set`, `Date`, `Uint8Array`, and other typed objects MUST NOT be traversed (their internal slots are not addressable as object keys). When `options.allowDangerousKeys === true`, the function SHALL be a no-op.
#### Scenario: dangerous keys are stripped recursively
- **WHEN** a caller invokes `stripPrototypePolluters({ a: { __proto__: {}, b: { constructor: 1 } } })`
- **THEN** the input mutates to `{ a: { b: {} } }` (both `__proto__` and `constructor` removed)
#### Scenario: class instances are not traversed
- **WHEN** a caller invokes `stripPrototypePolluters({ d: new Date(0), m: new Map([["__proto__", 1]]) })`
- **THEN** the `Date` and `Map` are unchanged (their internal slots are not addressed as enumerable string keys)
#### Scenario: allowDangerousKeys is a no-op
- **WHEN** a caller invokes `stripPrototypePolluters({ __proto__: {} }, { allowDangerousKeys: true })`
- **THEN** the input is unchanged
**Verified by:** `src/security/proto-guard/proto-guard.test.ts::"strips __proto__ / constructor / prototype recursively"` AND `src/security/proto-guard/proto-guard.test.ts::"does not traverse Date / Map / Set / Uint8Array internals"` AND `src/security/proto-guard/proto-guard.spec-conformance.test.ts::"allowDangerousKeys true makes the function a no-op"`
---
### Requirement: Non-mutating clone-and-strip
The package SHALL expose `cloneAndStrip<T>(value: T, options?: { allowDangerousKeys?: boolean }): T` that returns a deep clone of `value` with `stripPrototypePolluters` applied to the clone. The original input MUST NOT be mutated.
#### Scenario: original input is preserved
- **WHEN** a caller invokes `cloneAndStrip(input)` for `input = { __proto__: { polluted: true }, safe: 1 }`
- **THEN** the returned object equals `{ safe: 1 }` AND `input.__proto__` still has `polluted: true`
**Verified by:** `src/security/proto-guard/proto-guard.test.ts::"cloneAndStrip preserves the original input"`
---
### Requirement: OTel observability for prototype-guard runs
`stripPrototypePolluters` and `cloneAndStrip` SHALL emit a passive OTel span event named `serialize.proto-guard.strip` carrying a numeric attribute `keysRemoved` representing the count of dangerous keys deleted by the call. The span event MUST be emitted from production paths regardless of whether the caller has configured a tracer.
#### Scenario: keysRemoved attribute reflects the deletion count
- **WHEN** a caller invokes `stripPrototypePolluters({ __proto__: 1, a: { constructor: 2 } })` under an in-memory tracer
- **THEN** the recorded span event carries `keysRemoved: 2`
**Verified by:** `src/security/proto-guard/proto-guard.spec-conformance.test.ts::"emits serialize.proto-guard.strip event with keysRemoved count"`
---
### Requirement: structuredClone wrapper with structured error
The package SHALL expose `deepClone<T>(value: T): T` from `./codec/structured-clone` that delegates to `globalThis.structuredClone`. When `globalThis.structuredClone` is `undefined` or non-callable, the function SHALL throw `SerializationError` with code `STRUCTURED_CLONE.UNAVAILABLE`. `deepClone` SHALL be re-exported from the root `index.ts`.
#### Scenario: round-trip preserves nested structures
- **WHEN** a caller invokes `deepClone(input)` for any input that platform `structuredClone` accepts (objects, arrays, `Map`, `Set`, `Date`, `Uint8Array`, primitives)
- **THEN** the result is structurally equal to `input` AND identity-distinct (`!== input` for non-primitive values)
#### Scenario: missing platform support throws structured error
- **WHEN** `globalThis.structuredClone` is replaced with `undefined` AND a caller invokes `deepClone({})`
- **THEN** the call throws `SerializationError` with code `STRUCTURED_CLONE.UNAVAILABLE`
**Verified by:** `src/codec/structured-clone/structured-clone.test.ts::"round-trips nested objects / arrays / Map / Set / Date / Uint8Array"` AND `src/codec/structured-clone/structured-clone.spec-conformance.test.ts::"throws SerializationError(STRUCTURED_CLONE.UNAVAILABLE) when platform structuredClone is missing"`
---
### Requirement: JSON decoder opt-in post-strip
The JSON decoder (`./codec/json/deserialize`) SHALL accept an optional `stripPrototypePolluters?: boolean` field on `DeserializeOptions`. When `true`, the decoder SHALL run `stripPrototypePolluters` on the parsed value after the existing prototype-pollution reviver pass completes. The default value SHALL remain `false` so existing call sites are unaffected.
#### Scenario: opt-in flag runs the post-pass
- **WHEN** a caller invokes `deserialize(jsonText, { stripPrototypePolluters: true })` against input that produces nested `__proto__` keys via reviver-bypass tricks (escaped strings, etc.)
- **THEN** the returned value contains zero `__proto__` / `constructor` / `prototype` keys
#### Scenario: default behaviour is unchanged
- **WHEN** a caller invokes `deserialize(jsonText)` without the flag
- **THEN** the decoder behaves exactly as before this change shipped (the existing reviver-only guard is the only protection)
**Verified by:** `src/codec/json/json-decode.spec-conformance.test.ts::"stripPrototypePolluters true runs post-pass over decoded value"` AND `src/codec/json/json-decode.spec-conformance.test.ts::"default decoder behaviour is unchanged when flag is omitted"`
---
### Requirement: Subpath exports
The package SHALL expose two new subpath exports in `package.json::exports`: `./security/proto-guard` (resolves to `./dist/security/proto-guard/index.js` plus types) and `./codec/structured-clone` (resolves to `./dist/codec/structured-clone/index.js` plus types). Importing only `deepClone` from the structured-clone subpath SHALL NOT pull in the JSON or MessagePack codecs.
#### Scenario: subpath imports resolve
- **WHEN** a caller writes `import { deepClone } from "@suaveplan/serialize/codec/structured-clone"` AND `import { stripPrototypePolluters } from "@suaveplan/serialize/security/proto-guard"`
- **THEN** TypeScript resolves both imports
**Verified by:** `src/codec/structured-clone/structured-clone.spec-conformance.test.ts::"subpath ./codec/structured-clone resolves and tree-shakes"` AND `src/security/proto-guard/proto-guard.spec-conformance.test.ts::"subpath ./security/proto-guard resolves and tree-shakes"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,83 @@
# Tasks — Serialize proto-guard + structured-clone surface
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened.
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## Audit Note (2026-05-02)
**Minimal progress** — DEFERRED, do not archive.
- `packages/data/serialize/src/` has no `security/` directory. The `codec/` directory exists but contains no `structured-clone/` module.
- `grep -rln "stripPrototypePolluters\|deepClone\|proto-guard\|structured-clone" packages/data/serialize/src/` returns zero hits.
- `bunx turbo lint typecheck test --filter=@suaveplan/serialize` is GREEN — but neither `proto-guard` nor `structured-clone` ships.
- All 44 tasks remain: register `STRUCTURED_CLONE.UNAVAILABLE` + `PROTO_GUARD.FORBIDDEN_KEY` codes, port `stripPrototypePolluters` and `cloneAndStrip`, port `deepClone`, JSON-decoder option wiring, OTel span event, subpath exports, sibling `.md`s, root-barrel re-exports, conformance tests for both new modules.
## 1. Spec authoring
- [ ] 1.1 Read `../suaveplan/packages/universal/serialize/src/security/proto-guard.ts` end-to-end
- [ ] 1.2 Read `../suaveplan/packages/universal/serialize/src/codec/structured-clone/structured-clone.ts` end-to-end
- [ ] 1.3 Read `openspec/specs/serialize/spec.md`
- [ ] 1.4 Read `openspec/AGENTS.md` Sections 13 and 14
- [ ] 1.5 Draft `proposal.md` from `openspec/templates/proposal.md`
- [ ] 1.6 Fill in `## Source mapping` table — every requirement cites a line range OR appears in the "no source line" table with justification
- [ ] 1.7 Draft `specs/serialize/spec.md` from `openspec/templates/spec-delta.md`
- [ ] 1.8 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
## 2. Implementation
- [ ] 2.1 Register error codes `STRUCTURED_CLONE.UNAVAILABLE`, `PROTO_GUARD.FORBIDDEN_KEY` in `src/error-codes.ts`
- [ ] 2.2 Create `src/security/proto-guard/` feature module with `proto-guard.ts`, `proto-guard.types.ts`, `proto-guard.schemas.ts`, `proto-guard.md`, `index.ts`
- [ ] 2.3 Implement `stripPrototypePolluters(value, options?)` (in-place mutation; recursion across plain objects and arrays only)
- [ ] 2.4 Implement `cloneAndStrip<T>(value, options?)` (delegates to `deepClone` then `stripPrototypePolluters`)
- [ ] 2.5 Create `src/codec/structured-clone/` feature module with `structured-clone.ts`, `structured-clone.md`, `index.ts`
- [ ] 2.6 Implement `deepClone<T>(value)` over `globalThis.structuredClone` with `SerializationError` fallback
- [ ] 2.7 Add subpath exports `./security/proto-guard` and `./codec/structured-clone` to `package.json::exports`
- [ ] 2.8 Re-export `deepClone` and `stripPrototypePolluters` from the root barrel `src/index.ts`
- [ ] 2.9 Extend `src/codec/json/json-decode.ts` with `stripPrototypePolluters?: boolean` option (default `false`); when `true`, run post-pass after the existing reviver guard
- [ ] 2.10 Wire OTel span event `serialize.proto-guard.strip` with `keysRemoved: number` attribute
## 3. Tests
- [ ] 3.1 Unit tests at `src/security/proto-guard/proto-guard.test.ts` — recursion, non-traversal into class instances / Map / Set / Date / Uint8Array, in-place mutation, `cloneAndStrip` preserves input
- [ ] 3.2 Spec-conformance tests at `src/security/proto-guard/proto-guard.spec-conformance.test.ts` — `allowDangerousKeys: true` no-ops; OTel span event emitted with correct `keysRemoved` count via real in-memory `TracerProvider`
- [ ] 3.3 Unit tests at `src/codec/structured-clone/structured-clone.test.ts` — round-trip primitives / objects / arrays / Map / Set / Date / Uint8Array; `deepClone(undefined)` returns `undefined`
- [ ] 3.4 Spec-conformance tests at `src/codec/structured-clone/structured-clone.spec-conformance.test.ts` — `SerializationError` with `STRUCTURED_CLONE.UNAVAILABLE` thrown when `globalThis.structuredClone` is `undefined`; subpath import resolves
- [ ] 3.5 Spec-conformance test in `src/codec/json/json-decode.spec-conformance.test.ts` covering `stripPrototypePolluters: true` post-pass behaviour
- [ ] 3.6 Every `it(...)` carries `{ timeout: 5000 }`
## 4. Documentation
- [ ] 4.1 `src/security/proto-guard/proto-guard.md` — sibling doc (≥200 words, basic + advanced, full API)
- [ ] 4.2 `src/codec/structured-clone/structured-clone.md` — sibling doc (≥200 words, basic + advanced, full API, document `STRUCTURED_CLONE.UNAVAILABLE` runtime requirement)
- [ ] 4.3 README links both `.md` files under `## Modules`
- [ ] 4.4 Update `CLAUDE.md` `@suaveplan/serialize` entry to mention `stripPrototypePolluters` and `deepClone`
## 5. Pre-archive verification (the 5-gate triple)
- [ ] 5.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the whole monorepo
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/serialize` — green
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/serialize` — 100/100/100/100
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/serialize` — exit 0, no `error` findings
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/serialize` — green
- [ ] 5.6 `bunx turbo test --filter=...@suaveplan/serialize` — every dependent (network-plugin-protocol, network-websockets, network-plugin-webrtc, slsa, audit-logging, etl) passes
## 6. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [ ] 6.1 Generics threaded; `expectTypeOf` assertions on `cloneAndStrip<T>` return type
- [ ] 6.2 Zod schemas wired at every declared boundary (proto-guard options)
- [ ] 6.3 Both error codes (`STRUCTURED_CLONE.UNAVAILABLE`, `PROTO_GUARD.FORBIDDEN_KEY`) thrown by at least one production path
- [ ] 6.4 `allowDangerousKeys` strict mode tested both on and off
- [ ] 6.5 OTel span event emitted from production paths and asserted with a real in-memory `TracerProvider`
- [ ] 6.6 Lifecycle dedup — N/A (stateless helpers)
- [ ] 6.7 No retroactive tick — every box above reflects work done in this change
## 7. Archive
- [ ] 7.1 Fold spec delta into `openspec/specs/serialize/spec.md`
- [ ] 7.2 Move `openspec/changes/serialize-proto-guard/` → `openspec/archive/<yyyy-mm-dd>-serialize-proto-guard/`
- [ ] 7.3 Refresh `MANIFEST.md` if dep set changed (no expected change)