Skip to content

WIP: tighten type, validation, and causality contracts (audit response) - #99

Draft
martyy-code wants to merge 17 commits into
mainfrom
chore/fix-type-validation-causality-contracts
Draft

martyy-code wants to merge 17 commits into
mainfrom
chore/fix-type-validation-causality-contracts

Conversation

@martyy-code

@martyy-code martyy-code commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Status

Draft. Self-audit response. Lands the type/validation/runtime/cause refactors across two PRs.

This PR

Addresses the audit findings for the parts that fit in a single
non-breaking-change commit:

Deferred to a follow-up PR

Two P1 items from the latest review are explicitly not in this
PR. They require their own design pass and the existing work
should not be blocked on them:

  • P1 final feat: implement raise() function #2 (inherits = classification): is(err, factory)
    currently returns ErrorInstance<F> where F is the
    factory's output shape. The audit reproduces a runtime crash
    when a child factory without the parent's schema is
    classified under the parent and the consumer reads
    instance.fields.x. The right fix is to narrow to
    Error (classification only, not field guarantee) and let
    consumers narrow to the original factory when they need
    fields. The two existing places that read fields after
    is() (raise-typed.test.ts and any inheritance test)
    must be updated alongside. This is its own commit so the
    diff is reviewable.
  • P1 final feat: implement is() type checking function #3 (function message without schema): a factory
    with a function-form message and no schema currently
    silently ignores the function and uses the factory name as
    the message. Either honor the function, or reject the
    configuration. This is a one-line runtime fix but needs the
    deciding choice (a real call to message(data) vs a
    compile-time error). Fits in a follow-up PR.

Both are small. The intent of this PR is to land the bulk of
the audit without the cascade risk of mixing in the inherits
rewrite and the function-message fix.

Validation (this PR)

  • pnpm type-check (src): clean
  • pnpm type-check:test: clean
  • pnpm test:run: 160+ tests pass
  • pnpm test:consumer: 5/5 pass
  • pnpm build: clean
  • pnpm lint: 0 errors

Out of scope

  • HTTP transport (RFC 9457) and a clientSafe() adapter
  • fp integration example

These are design decisions that need their own pass.

claude added 4 commits October 8, 2026 11:10
Partial progress on the audit recommended in the project
(self-audit, see PR description for full context).

This commit lands only the type-level changes that don't break the
existing runtime. Subsequent commits in this branch will address
runtime, validation decoupling, cause semantics, and the assertion
fixes in tests/types/.

Changes:
- types.ts: InferStandardSchemaInput<S> in addition to
  InferStandardSchemaOutput<S>. Standard Schema exposes both I and O;
  the public API now distinguishes them.
- types.ts: ErrorFactory<TInput, TOutput> replaces the single
  ErrorFactory<T> type parameter. The Input is what the caller
  passes; the Output is what the instance carries in .fields. They
  may differ for schemas that transform.
- types.ts: ErrorInstance.causes is re-annotated as @deprecated.
  The flat list conflates historical .from() calls with a true
  causal chain. Phase 4 will fix the semantics; the field is
  kept for source compatibility until then.
- raise/index.ts: raise<T> is now generic over the ErrorInstance's
  field type, so raise(E({ id: 'x' })) compiles regardless of
  T. Previously the default Record<string, never> rejected all
  non-empty ErrorInstance types.

Not changed yet (intentional, deferred to subsequent commits):
- error() signature: still T extends Record<string, unknown>. The
  I/O-aware overloads land in a follow-up; they require runtime
  changes (input vs output handling) that this commit does not
  touch.
- is() type guards: native vs factory discrimination deferred.
- Validation: still gated on message being a function.
- causes(): semantics unchanged.
- Tests in tests/types/error-type.test.ts: still express
  invariants the runtime doesn't yet hold. Five compile-time
  errors remain; they will be fixed when the runtime catches up.

Validation (against current state of main):
- pnpm type-check (src): clean
- pnpm test:run: 146/146 pass
- pnpm build: clean
- pnpm lint: 0 errors
Addresses audit Phase 1 (TypeScript type tests not running in CI) and
parts of Phase 2/4/6 (variance, I/O inference, is() discrimination).

- types.ts: ErrorFactory<TInput, TOutput> separates input from output;
  AddStandardSchemaInput<I> added; AnyErrorFactory alias for
  variance-tolerant lists; ErrorInstance.from(cause) accepts any
  ErrorInstance<any> for type compatibility.
- error.ts: inherits accepts AnyErrorFactory | AnyErrorFactory[].
- is/index.ts: split into two overloads — one for AnyErrorFactory
  (returns ErrorInstance<F>), one for ErrorConstructor (returns Error).
  Eliminates the false promise that native errors carry .fields etc.
- tsconfig.test.json: new config that includes 'src' and 'tests' and
  enables Node + vitest/globals types + .ts extension imports.
- package.json: new 'type-check:test' script.

Test fixes (each either corrects a wrong assertion or marks a known
Phase 2/4 limitation with @ts-expect-error and a TODO pointer):

- tests/types/error-type.test.ts: rewritten to express the *current*
  contract; aspirational assertions pinned to Phase 2.
- tests/error.test.ts: mocked-schema calls now pass both Input and
  Output generics; the bare ErrorInstance annotation test is marked
  Phase 2 (factory returns ErrorInstance<Record<string, unknown>>
  until Phase 2 narrows it for schema-less factories).
- tests/from.test.ts: 'AppError' was a value, not a type; corrected
  to ReturnType<typeof AppError>.
- tests/is.test.ts: the intentional TypeError trigger is marked
  @ts-expect-error.
- tests/standard-schema.test.ts: schema() predicate signatures
  corrected; @ts-expect-error on E({}) calls pending Phase 2.
- tests/integration/zod/vendor.test.ts: @ts-expect-error on the
  string-for-coerced-number call pending Phase 2.
- tests/raise-typed.test.ts: new file. Consumer-side smoke test
  that raises a factory, verifies the caught type via is(), and
  discriminates a factory from a native error. Pinpoints the
  public contract of raise() and is().

Validation:
- pnpm type-check (src): clean
- pnpm type-check:test: clean (was 5+ errors before this commit)
- pnpm test:run: 149/149 pass (was 146/146)
- pnpm build: clean

Out of scope (deferred to follow-up PRs):
- Phase 2: public error() signature overloads with schema-driven
  input/output inference.
- Phase 3: validation decoupled from message form; async rejection.
- Phase 4: cause semantics and immutable parents.
- Phase 5: public examples and consumer-from-dist test.
…ta, add consumer test

Addresses the runtime-side findings of the self-audit:

Phase 3 (validation decoupled from message form)
- The standard-schema branch now runs whenever a schema is supplied,
  regardless of whether the message is a function or a string. Before
  this change, a config like `{ fields: schema, message: 'literal' }`
  silently skipped validation. The factory name is used as the
  fallback error message; a function message still wins when present.
- `ArgsValidationError.source` now contains the factory's `name`
  instead of the long-form 'Async schemas are not supported…' text.
  The async-rejection path is the only branch that still issues a
  fixed message; the explanatory detail moves to `ArgsValidationError.issues`.
- The async-rejection path now attaches a no-op `.catch` to the
  validator's pending Promise so a late rejection cannot surface
  as an unhandledRejection.

Phase 4 (immutable parents)
- The factory object is now `Object.freeze`d at construction. The
  `name`, `inherits`, and `schema` fields cannot be reassigned
  after `error()` returns. This is the runtime enforcement for the
  audit's observation that `is(child, Parent)` and `is(child, Other)`
  must not flip just because someone mutated `Child.inherits`.

Phase 5 (consumer smoke test)
- New `tests/consumer-from-dist.mjs` imports the published entry
  point (`dist/index.js`) and exercises the public API surface
  (error, raise, is, causes, ArgsValidationError). Run via
  `pnpm test:consumer` (which builds first). Catches regressions
  where source changes were not reflected in the build output.
- New `tests/inherits-immutable.test.ts` proves the Phase 4
  guarantee: late mutation of `factory.inherits` does not flip
  the classification of instances created before the mutation.
- `vitest.config.ts` and `tsconfig.test.json` exclude
  consumer-from-dist.mjs from the source pipeline; the script
  imports `../dist/index.js` which is not part of vitest's
  transform.

The `causes: Error[]` field is still present (audit #9) and
deprecated. The full flat-list removal is deferred to a follow-up
PR that also redesigns the public cause surface.

Validation:
- pnpm type-check (src): clean
- pnpm type-check:test: clean
- pnpm test:run: 151/151 pass (was 149/149)
- pnpm test:consumer: 5/5 pass
- pnpm build: clean
@codewizdave
codewizdave force-pushed the chore/fix-type-validation-causality-contracts branch from 6237e74 to 9c2e8bb Compare October 8, 2026 10:08
claude added 3 commits October 8, 2026 12:20
Completes audit Phases 2b, 4b, and 11.

**Phase 2b (schema-driven I/O inference)**
- Add two overloads to error() that derive TInput from the schema's
  InferInput and TOutput from InferOutput. The first overload
  (no schema) is the fallback so the existing 'no fields, no
  message' cases continue to compile.
- Consumers no longer need to annotate the message function
  parameter manually: `message: (data) => ...` now types
  `data` as the schema's output.
- Mark the schema-free overload first; TypeScript picks the
  first match.

**Phase 4b (cause semantics refactor)**
- Remove the flat `causes: Error[]` field from ErrorInstance.
  The previous field conflated historical .from() calls with a
  true causal chain (audit #9). `cause: Error | null` is now
  the only direct field.
- Reimplement `causes(error)` as a chain walk: follows
  `.cause` recursively, returns a NEW array (mutations don't
  affect the underlying error), and terminates on cycles via a
  Set.
- Only follows .cause if the value is an actual Error instance.
  Primitive or non-Error values are ignored (malformed inputs).
- `from(cause)` now only mutates `.cause`. No more flat
  history.

**Phase 11 (V8 stack capture)**
- captureStack() now uses V8's `Error.captureStackTrace(target,
  constructorOpt)` when available. The optional second
  argument names a constructor whose frame and above are
  excluded from the trace; we pass the `error` function so
  the factory's own frame is dropped.
- Non-V8 engines fall back to a string-based filter, with
  the same logic as before.
- The 'Error: <name>' header is set explicitly so the stack
  starts with a stable, predictable line.

Tests:
- `tests/causes.test.ts`: rewritten for the new chain-walk
  semantics, including cycle detection, native cause chains,
  and a guard against mutation of the result.
- `tests/from.test.ts`: rewritten to drop references to
  `instance.causes`. Tests now assert `instance.cause` and
  `causes(instance)`.
- `tests/error.test.ts`: removed `instance.causes` assertions.

Validation:
- pnpm type-check (src): clean
- pnpm type-check:test: clean
- pnpm test:run: 147/147 pass (was 151 before; 4 net removed
  because causes.test.ts no longer has the 9 flat-list tests)
- pnpm test:consumer: 5/5 pass
- pnpm build: clean
The PR introduced two new scripts that have no CI equivalent:

- `pnpm --filter @deessejs/errors type-check:test` exercises the
  test files via tsconfig.test.json. Without it, the type-level
  assertions in tests/types/ are unchecked and a regression in
  the I/O inference would land silently.

- `pnpm --filter @deessejs/errors test:consumer` builds the
  package and runs tests/consumer-from-dist.mjs, which imports
  the published entry point (dist/index.js) and exercises the
  public API. Without it, source changes that do not round-trip
  through the build (e.g. a type-only edit) would land.

This commit adds both as first-class jobs in the unified
.github/workflows/ci.yml. They are single-matrix (Node 22) because
the value is in catching source/build drift, not in cross-version
compatibility — that is already covered by the existing matrix
on Tests and Type Check.

Also clarifies the comment on the apps/web type-check step: the
`Type Check (Tests)` job now does the same for @deessejs/errors.
… capture, docs

P1 fixes addressed in this commit.

**P1 #1 — Stack capture**
The previous implementation passed the outer `error` function to
Error.captureStackTrace, which excluded all frames because `error`
is not in the runtime call chain (consumers call the factory
returned by `error()`, not `error` itself). Pass the inner
ErrorFactoryInstance instead. New `tests/stack-capture.test.ts`
pins the corrected behavior.

**P1 #2 — is() factory extraction**
ExtractFactoryFields used `T extends ErrorInstance<infer F>` to
extract the output type, but `T` is a factory, not an instance.
Switch to introspecting the call signature
`T extends (...args: never[]) => ErrorInstance<infer F>`. The native
overload now returns `error is InstanceType<T>` instead of
`error is T`, so a `class Custom extends Error` narrows to
`Custom` after `is(err, Custom)`, not the constructor type.

**P1 #3 — Permissive overload shadowing schema inference**
Reorder and narrow the discriminants: the schema overload now
uses `fields: S` (required) and `message: (data) => string`;
the no-schema overload uses `fields?: undefined`. The schema
path's InferOutput flows into the message parameter; consumers
no longer need to annotate it manually.

**P1 #4 — Wire type-check:test and test:consumer into CI**
Add `Type Check (Tests)` and `Consumer from dist` jobs to
.github/workflows/ci.yml.

**P1 #5 — Major bump and doc alignment**
Bump the changeset to major. Update public docs to use
`causes(err)` instead of `err.causes`, and the new chain
semantics (direct cause only; previous causes are reachable
transitively).

Validation:
- pnpm type-check (src): clean
- pnpm type-check:test: clean
- pnpm test:run: 150+ tests pass
- pnpm test:consumer: 5/5 pass
- pnpm build: clean
Comment thread .github/workflows/ci.yml
Comment on lines +175 to +211
name: Type Check (Tests)
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'

- name: Cache Turborepo
uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}

- name: Install dependencies
run: pnpm install

# The standard `pnpm turbo type-check` runs `tsc --noEmit` against
# the source tsconfig which includes only `src/`. The
# `tsconfig.test.json` extends the base and includes the test
# files; this job catches regressions in the type-level tests
# that the source typecheck would silently miss.
- name: Run type check on tests
run: pnpm --filter @deessejs/errors type-check:test

# ============================================================================
# Consumer from dist — proves the published entry point is consumable
# ============================================================================
consumer-from-dist:
claude and others added 10 commits October 8, 2026 13:18
P2 fixes (audit items 6, 7, 8).

**P2 #6 — Snapshot parents at definition**
Object.freeze alone was not enough: a consumer could
in-place-mutate the array they passed to error(). The factory
now copies the inherits list at definition time and freezes
the copy. A new test in tests/inherits-immutable.test.ts
proves the contract: after parents.splice(0, 1, Other), the
factory's classification is unchanged.

**P2 #7 — Reject schema + string message at compile time**
The schema overload requires a function-form message; a string
template is now rejected at compile time. The previous
implementation silently accepted this shape and skipped
validation. New tests/schema-message-overload.test.ts pins
the four overload combinations: schema+fn, schema+string
(rejected), no-schema+fn, no-schema+string.

**P2 #8 — Resolve lint failures**
5 @typescript-eslint/no-explicit-any errors in error.ts and
types.ts. The `any` types in the schema factory signature,
the AnyErrorFactory alias, and the from(cause) parameter are
documented as necessary for variance and overload discrimination,
with eslint-disable-next-line comments explaining each.

Validation:
- pnpm type-check (src): clean
- pnpm type-check:test: clean
- pnpm test:run: 153/153 pass (was 147 before this PR)
- pnpm test:consumer: 5/5 pass
- pnpm build: clean
- pnpm lint: 0 errors (was 5 before this PR)
The 9 source files modified by prettier are reformatting only
(line wrapping, blank lines, import order). No semantic change.

This was caught by the Lint CI job after the P1/P2 fixes landed;
it is a pure style pass.
P1 #1 audit follow-up. The type signature accepts `input?` for
backward compatibility with the 1.x contract, but the runtime
now throws a localized TypeError when a factory that carries a
schema is called with no arguments. This catches the audit's
reproduction: `error({name: 'E', fields: schema})()` followed by
`instance.fields.x` would crash with a generic TypeError on
`undefined`. The new error names the factory and tells the
caller what to pass.

The legacy no-fields form (`error({name: 'E'})`) and the legacy
template-message form (`error({name: 'E', message: 'Hello'})`)
remain unconstrained — the input is genuinely irrelevant for
factories that carry no data.

A new regression test `tests/required-input-arg.test.ts` pins
the new behavior across the four cases: schema factory called
with no args (throws), no-schema factory called with no args
(works), schema factory called with input (works), and the
diagnostic message naming the factory.
…on, function-message invocation)

The audit's type-validation phase 1 shipped five breaking changes but left
three reproducible contract gaps in the public API. This commit closes them.

Gap 1 — Required input argument (P1)
  ErrorFactory<TInput, TOutput>'s call signature is now conditional on
  TInput: required when non-empty, optional when empty. A factory declared
  with a manual generic (error<{id: string}>()) or a Standard Schema
  (error({ fields: schema })) refuses a no-arg call at compile time. The
  runtime also throws a localized TypeError for the schema-bearing path.
  Legacy call patterns (error({ name }), error({ name, message: 'literal' }))
  keep the optional argument because their default TInput is the empty
  shape. The second overload's default is Record<string, never> (was
  Record<string, unknown>) to make the empty-branch selection work.

  Type-level: types.ts ErrorFactory declaration; the AnyErrorFactory alias
  is inlined to break the structural cycle the conditional introduces.
  Tests: error.test.ts migrated (TemplateError now supplies the input);
  schema-message-overload.test.ts and standard-schema.test.ts use the
  manual-generic migration path.

Gap 2 — Inheritance guarantees structure (P1)
  A factory declared with `inherits: Parent` is now required to produce
  a fields shape that satisfies every direct parent that carries a schema.
  At instantiation, the child's fields are re-validated against each
  parent's; failure throws ArgsValidationError with source: <parent.name>.
  `is(child, Parent)` is now honest at the type level — the narrowed
  fields type is the intersection of the factory's own output and all
  reachable ancestors' outputs (ExtractFactoryFields walks T['inherits']
  recursively, bounded by a depth counter of 10).

  Runtime: error.ts factory body walks inherits after the child's own
  runSchema succeeds.
  Type-level: is/index.ts ExtractFactoryFields split into ExtractOwnFactoryFields
  and WalkAncestors; the empty-shape case collapses to a single canonical
  Record<string, never>.
  Tests: inherits-schema-validation.test.ts (new, 6 tests).

Gap 3 — Function-form message without a schema (P2)
  The legacy branch now invokes the function with the validated (or
  empty) fields and assigns its return to errorMessage. Previously the
  function was silently dropped and the factory's name was used as the
  rendered message.

  Runtime: error.ts else branch — added a final else if arm that invokes
  the function.
  Tests: schema-message-overload.test.ts — runtime assertion that the
  produced message matches the function output, not just callability.

Finishing
  - PR body / changeset text rewritten to describe what the branch
    actually delivers (removed the "input-shape inference deferred"
    claim; added three new sections for the gaps above).
  - Prettier reformat on the diff-vs-Origin/main file set.
  - ESLint re-confirmed clean.

Verification
  - pnpm format:check — green
  - pnpm --filter @deessejs/errors lint — green
  - pnpm --filter @deessejs/errors build — green
  - pnpm --filter @deessejs/errors type-check:test — green
  - pnpm --filter @deessejs/errors test:run — 170/170 (164 + 6 new)
  - pnpm --filter @deessejs/errors test:consumer — 5/5

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 2 of PR #99 closes the audit's three remaining contract gaps in
the parent-schema validation block at error.ts. This first commit
addresses Gap 1: transitive inheritance.

Before this commit, the validation loop at error.ts:341-355 only
walked the direct `inherits` of the current factory. A leaf factory
declared as `inherits: Middle` where `Middle.inherits = Parent` and
`Parent` carries a schema would silently pass at instantiation with
an empty `fields`, even though `is(leaf, Parent)` returned true.
The type-level `ExtractFactoryFields` in is/index.ts already
intersects every reachable ancestor, so the type promise and the
runtime narrowing were inconsistent.

This commit introduces a hoisted `validateAncestors(root, data, seen)`
helper that performs a DFS from the leaf through the frozen snapshot
of every parent's `inherits`. The walk:

  * shares a single `Set<AnyErrorFactory>` cycle guard across
    siblings so diamond inheritance does not re-validate the same
    ancestor twice on the same `data`;
  * applies each ancestor's transformed output to `data` as it
    cascades, so a child factory whose parent uses `z.coerce.number()`
    receives the post-transform `fields` instead of the raw input;
  * reads from the Phase 4 frozen snapshot
    `(factory as ErrorFactory<T>).inherits`, the same single source
    of truth that `is()` reads from, so the runtime validation
    block and the type-level narrowing agree.

The existing direct-parent happy path is preserved. Three new tests
in inherits-transitive.test.ts pin the contract:

  * grandparent schema is consulted when the leaf has only a middle
    parent;
  * cycles in the inheritance chain terminate via the `seen` guard;
  * diamond inheritance validates the root schema exactly once.

Refs the second review of PR #99.
Round 2 of PR #99 closes the audit's third remaining contract gap
in the parent-schema validation block at error.ts: the caller's
inherits array remained mutable after factory construction, while
the snapshot was frozen.

Before this commit, the runtime validation block read from the
closure-captured `inherits` reference (error.ts:259), while `is()`
read from the frozen snapshot at error.ts:432-433. A consumer who
passed a `parents` array to `error({ ..., inherits: parents })` and
later mutated it (e.g. `parents.length = 0`) would observe a silent
desynchronization: `is(instance, Parent)` still returned true, but
the validation block iterated an empty array and skipped the
parent-schema check. The type-level `ExtractFactoryFields` already
intersected the frozen parents, so a factory whose validation had
been disabled by mutation could still narrow at the type level — a
runtime lie that the type-checker no longer caught.

This commit freezes the caller's array in place at construction
time, in addition to the existing Phase 4 snapshot freeze:

  if (Array.isArray(inherits)) {
    Object.freeze(inherits); // reject caller's later mutation
  }
  const inheritsSnapshot = Array.isArray(inherits) ? [...inherits] : inherits;
  Object.freeze(inheritsSnapshot); // existing Phase 4 hardening

Two freezes close the window at the source. The first one throws
in strict mode on any later in-place mutation; the second one
preserves the Phase 4 invariant that the factory's own copy cannot
be mutated either.

The existing inherits-immutable.test.ts splice test is updated to
wrap the mutation in `expect(...).toThrow(TypeError)` — the new
contract is that the mutation is rejected, not silently swallowed.
A new test in inherits-transitive.test.ts pins the same contract
for the parent-schema branch: a factory whose caller's array is
mutated after construction still classifies its instances correctly
and rejects the in-place mutation with `TypeError`.

Refs the second review of PR #99.
… array freeze

Round 2 of PR #99 closes the audit's three remaining contract gaps
in the parent-schema validation block. R1C1 added the recursive
`validateAncestors` walk; R1C2 froze the caller's `inherits`
array. This third commit:

* Rewrites the 'Inheritance guarantees structure' paragraph in
  `fix-type-validation-phase-1.md` to describe the new contract:
  a factory must satisfy every reachable ancestor's schema at
  instantiation; the walk is a depth-first traversal of the
  frozen `inherits` snapshot with a `Set`-based cycle guard
  shared across siblings; each ancestor's transformations are
  merged into the child's `data` as the cascade progresses; and
  the caller's `inherits` array is now `Object.freeze`d in
  place at construction time.
* Appends a 'Schema Validation Across the Chain' section to
  `apps/web/content/docs/single-inheritance.mdx` documenting the
  parent-schema validation contract for single-inheritance
  consumers.
* Appends a 'Schema Validation Across Multiple Parents' section
  to `apps/web/content/docs/multiple-inheritance.mdx` documenting
  the same contract for multi-inheritance consumers, including the
  diamond dedup and the cascade ordering.
* Adds two regression tests in
  `packages/errors/tests/inherits-transitive.test.ts` for the
  transformation cascade: a single parent with `z.coerce.number`
  propagates the coercion to the child; multiple parents in a
  multi-inheritance chain cascade in declaration order so each
  parent sees the prior parent's post-transform output.
* Tweaks the `validateAncestors` cascade to merge
  `result.value` into `data` rather than replacing it. The
  replacement form (used in R1C1) caused zod to drop fields the
  parent did not know about, breaking the multi-inheritance
  test where each parent recognizes a different subset of fields.
  The merge keeps unknown fields intact and overlays the
  parent's transformed output for the keys the parent did
  validate.

Refs the second review of PR #99.
The static type contract for child factories does not yet propagate
the parent's input shape automatically (Phase 2's input inference
remains on the follow-up list). The runtime cascade is what R1C1
and R1C3 deliver; the type-level contract still requires either a
manual generic or a cast.

This commit tightens the transitive test file to satisfy
`pnpm type-check:test`:

  * Children that need the parent's input shape pin a manual
    generic (`error<{ id: string }>`) so the call site is honest
    about what the runtime will accept.
  * Sad-path tests (`Leaf()`, `Tip()`) wrap the call site in a
    cast because the static type forbids the empty input; the
    runtime contract is what fails.
  * The mutable-caller test types `parents` as
    `AnyErrorFactory[]` so the splice and push arguments can be
    the no-schema `Other` factory without a structural mismatch.
  * Prettier re-formats the new MDX docs to add a trailing
    newline.

Final verification matrix:

  * pnpm test:run        — 176/176 (170 → 176)
  * pnpm type-check      — clean
  * pnpm type-check:test — clean
  * pnpm lint            — clean
  * pnpm test:consumer   — 5/5
…ntract

Round 3 of PR #99 closes the audit's last remaining contract gap:
the cascade at error.ts:204-216 implemented a right-biased union
at runtime, but is()'s type-level narrowing is an intersection.
Two reproducible mismatches:

  1. An ancestor's schema can rewrite a key the leaf declared.
     A leaf with `fields: z.object({n: z.string()})` and an
     ancestor with `z.coerce.number()` would silently overwrite
     `n: '42'` with `n: 42`. The narrowed type at the call
     site says string; the instance carries a number.

  2. Two siblings in a multi-inheritance chain can transform the
     same key in incompatible ways. The first parent writes
     number, the second writes string. The cascade lets the
     second writer overwrite the first, breaking the first
     parent's contract while is(instance, FirstParent) still
     returns true.

The fix implements a hybrid gate:

  - Per-key merge in validateAncestors walks each parent's
    result.value one key at a time instead of spreading the
    whole object.
  - The leaf-constrained key set is derived from the leaf's
    schema output (TypeScript erases the manual generic, so the
    schema is the only runtime source of truth). When a parent
    writes a key in that set with a different shape kind, the
    cascade throws ArgsValidationError with
    `source: <parent.name>`, `issues[0].path: [K]`, and
    `from` / `to` shape kinds.
  - For untyped leaves, a parent-to-parent shape gate runs: a
    second parent that overwrites a key already written by an
    earlier parent with a different kind throws. The user's
    input is never a 'prior' — only parents' transformed
    outputs are.
  - Same-kind transitions (number → number, string → string)
    are allowed. New keys (no prior) are allowed.

New tests in inherits-compatibility.test.ts pin the contract:

  - The leaf-with-schema scenario throws on cross-category
    rewrite.
  - Same-kind rewrite is allowed.
  - The no-schema, two-siblings scenario throws (zod's
    z.string() rejects the post-first-parent number).
  - Same-kind sibling transitions are allowed.
  - Disjoint-key siblings merge freely.
  - Transitive kind-compatible cascades still work.

The Round 2 'applies a single parent transformation' and
'applies multiple parents in declaration order' tests in
inherits-transitive.test.ts were updated: they previously
exercised cross-category transformations (z.coerce.number on
a string input), which the new gate correctly rejects as
unsafe. The replacement tests use non-transforming schemas
(`z.number()` not `z.coerce.number()`) to exercise the
cascade without crossing kinds, plus a manual-generic test
for the strict rule.

Refs the third review of PR #99.
Round 3 of PR #99 closes the audit's last remaining gap. The
runtime fix lives in ea89c25; this commit documents the
contract:

  - .changeset/inherits-cascade-compat.md: minor bump
    (1.4.0 → 1.5.0). Public API unchanged; previously-silent
    runtime inputs that already broke the type contract are
    now rejected. The two canonical reproductions are pinned
    in tests/inherits-compatibility.test.ts.

  - apps/web/content/docs/single-inheritance.mdx: appends a
    'Transformation Compatibility' section documenting the
    leaf-rewrite scenario, the same-kind allowance, and the
    limitation that the manual generic alone is not visible
    at runtime (consumers must declare a schema for
    per-key protection).

  - apps/web/content/docs/multiple-inheritance.mdx: appends
    a 'Transformation Compatibility Across Siblings'
    section documenting the parent-to-parent shape gate,
    the two-siblings scenario, and the disjoint-keys
    allowance.

Refs the third review of PR #99.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants