Skip to content

feat(core)!: reject {} at the last five config slots — Scalar|AtLeastOne (P20) - #507

Merged
rejifald merged 1 commit into
mainfrom
refactor/scalar-at-least-one-p20
Jul 24, 2026
Merged

feat(core)!: reject {} at the last five config slots — Scalar|AtLeastOne (P20)#507
rejifald merged 1 commit into
mainfrom
refactor/scalar-at-least-one-p20

Conversation

@rejifald

Copy link
Copy Markdown
Owner

What

Carves P20 (No empty-object config) out of the monolithic contract-freeze sweep (#470) as a standalone hard-break against main. Closes the last of R6's P20 backlog.

The five StitchConfig slots R6 flagged as bare all-optional *Options bags now type their object form so the opaque {} is a compile error:

slot before after
hooks Hooks AtLeastOne<Hooks>
input InputSchemas AtLeastOne<InputSchemas>
multipart MultipartOptions MultipartNesting | AtLeastOne<MultipartOptions>
stream StreamOptions StreamDecode | AtLeastOne<StreamOptions>
sse SseOptions boolean | AtLeastOne<SseOptions>

The all-defaults case is now the scalar, not the opaque {}: sse: true, stream: 'ndjson', multipart: 'dot' (P12/P13). expandShorthand folds each into its envelope at compose time (multipart: 'dot'{ nesting }, stream: 'ndjson'{ decode }, sse: true{ reconnect: true }, sse: false clears the slot), so the engine and __config still only ever see the resolved object form — ResolvedStitchConfig omits + re-declares these five, and compose writes chained hooks / normalized input through the resolved view.

Why

docs/CONTRACT.md P20: the empty object {} must not be a valid value at a config slot — where {} would mean "enable with defaults", that case must be a scalar. This is the enforcement teeth behind P13 and refines P15.

Notes

  • scripts/contract-violations.baseline.json shrinks 5 → 0 (R6 fully cleared).
  • Whole-entry bundle ticks 24 → 25 kB min+gzip (advertised sizes updated across all 7 sites); import { stitch } stays 20 kB.
  • Regenerated playground completions (all 5 slots' detail/info updated).
  • Tests: composition.spec asserts the new scalar folds resolve correctly; new config-at-least-one.spec pins the {}-rejection at each slot with @ts-expect-error (enforced by check:types).

Gate

build · check:types (full workspace + test tsconfig) · test (1210 pass) · check:contract (0 known violations) · check:lint · prettier --check — all green. Full pre-push gate (yakir bundle-advertised-size + build-docs + exports) passed.

Breaking

BREAKING CHANGE: StitchConfig.hooks, input, multipart, sse, and stream no longer accept the empty object {}. Enable-with-defaults is now the scalar — sse: true, stream: 'ndjson', multipart: 'dot' — and hooks/input require at least one field set. Object forms with ≥1 field are unchanged.

🤖 Generated with Claude Code

…stOne (P20)

The five `StitchConfig` slots R6 flagged as bare all-optional `*Options` bags —
`hooks`, `input`, `multipart`, `sse`, `stream` — now type their object form so
the opaque `{}` is a **compile error** (CONTRACT.md P20):

- `hooks?: AtLeastOne<Hooks>` and `input?: AtLeastOne<InputSchemas>` (no scalar)
- `multipart?: MultipartNesting | AtLeastOne<MultipartOptions>` and
  `stream?: StreamDecode | AtLeastOne<StreamOptions>` (P12 dominant-field scalar)
- `sse?: boolean | AtLeastOne<SseOptions>` (P13 enable-with-defaults toggle)

The all-defaults case is now the scalar, not the opaque `{}`. `expandShorthand`
folds each scalar into its envelope at compose time — `multipart: 'dot'` →
`{ nesting }`, `stream: 'ndjson'` → `{ decode }`, `sse: true` →
`{ reconnect: true }`, and `sse: false` clears the slot — so the engine and
`__config` still only ever see the resolved object form (ResolvedStitchConfig now
omits + re-declares these five). compose writes the chained `hooks` / normalized
`input` through the resolved view, past the authoring-side `AtLeastOne` gate.

Clears the last of R6's P20 backlog: `scripts/contract-violations.baseline.json`
shrinks from 5 to **0**. Whole-entry bundle ticks 24→25 kB min+gzip (advertised
sizes updated across all sites); `import { stitch }` stays 20 kB.

BREAKING CHANGE: `StitchConfig.hooks`, `input`, `multipart`, `sse`, and `stream`
no longer accept the empty object `{}`. Enable-with-defaults is now the scalar —
`sse: true`, `stream: 'ndjson'`, `multipart: 'dot'` — and `hooks`/`input` require
at least one field set. Object forms with ≥1 field are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rejifald
rejifald merged commit 11ac432 into main Jul 24, 2026
12 checks passed
@rejifald
rejifald deleted the refactor/scalar-at-least-one-p20 branch July 24, 2026 16:56
rejifald added a commit that referenced this pull request Jul 29, 2026
…erivation (P16) (#525)

Broken out of #470. Stacked on #501.

**P16 — one implementation, five consumers.** `StitchQueryOptions` and the whole query-key
derivation path were BYTE-COPIED into all five framework bindings: react, vue, svelte, solid and
angular each carried their own private `nameOf`, `isSecretHeader`, `keyInputFor` and
`stitchQueryOptions`. Five copies of a security-relevant redaction list is five chances to drift,
and the copies had ALREADY drifted from each other.

They all move into `@stitchapi/query-core`, the framework-agnostic keystone the bindings already
depend on, and are exported for the bindings to re-export:

    nameOf(stitch)            — the stable, human-meaningful first key segment
    keyInputFor(input)        — the sanitised input copy (secret headers/params redacted)
    deriveQueryKey(s, input)  — [nameOf(s), keyInputFor(input)]
    StitchQueryOptions<T>     — the TanStack-shaped options type
    stitchQueryOptions(...)   — the overloaded helper

Net effect: about 715 lines deleted from the bindings against ~166 added in query-core.

**The secret denylist is no longer a copy either.** `keyInputFor` now calls core's exported
`isSecretKey` predicate instead of each binding maintaining its own hard-coded header list. A
secret stem registered with `registerSecretKey` is now honoured by every binding's query key, which
was never true of the copies — this is the drift that mattered most, since a missed stem means a
credential lands in a TanStack cache key.

**Fix — react and angular no longer accept-and-ignore a streaming flag.** `UseStitchOptions` and
`InjectStitchOptions` extended the core options INCLUDING `streaming`, then never read it: setting
it compiled and silently did nothing. Both now `Omit` it, so the mistake is a type error.

**P9 — angular `InjectInput` → `MaybeSignal`.** The type describes "a value, a signal, or a thunk",
which is not an input and not angular-specific; `MaybeSignal` says what it is.

Hard break, no aliases — pre-GA, per P19 as amended in #470.

NOTE on the contract ratchet: it reports "5 known, baselined" here rather than 0 because #501's
branch predates #507, which cleared those five R6 entries on main. The baseline file is UNCHANGED
by this commit — no new violations. It returns to 0 once #501 catches up with main.

Verified: workspace typecheck clean, full workspace test suite green, check:exports green for
every package + companion, contract ratchet introduces nothing new, docs-links 110 routes OK,
prettier clean, apps/docs production build passes.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
rejifald added a commit that referenced this pull request Jul 30, 2026
…drop the bare queryOptions alias (P3/P9) (#501)

* refactor(query)!: rename the query result type to StitchQueryResult; drop the bare queryOptions alias (P3/P9)

Extracts the query-family slice from #470 (the contract-freeze sweep). The
read-back query state is a produced shape, so `StitchQueryState`→`StitchQueryResult`
(and `UseStitchReturn`→`UseStitchResult` in react/vue) — hoisted in query-core,
re-exported by the five TanStack-family bindings. The bare `queryOptions` (a
`@deprecated` alias of `stitchQueryOptions` that collided with TanStack's own
`queryOptions`) is removed. `StitchQueryOptions` — the deliberate TanStack-mirror
options type (P3 carve-out / P22) — keeps its name. Hard break (P19, pre-GA).

BREAKING CHANGE: `StitchQueryState`→`StitchQueryResult`, `UseStitchReturn`→
`UseStitchResult`, and the bare `queryOptions` alias is removed (use
`stitchQueryOptions`).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(query-core)!: single-source StitchQueryOptions + query-key derivation (P16) (#525)

Broken out of #470. Stacked on #501.

**P16 — one implementation, five consumers.** `StitchQueryOptions` and the whole query-key
derivation path were BYTE-COPIED into all five framework bindings: react, vue, svelte, solid and
angular each carried their own private `nameOf`, `isSecretHeader`, `keyInputFor` and
`stitchQueryOptions`. Five copies of a security-relevant redaction list is five chances to drift,
and the copies had ALREADY drifted from each other.

They all move into `@stitchapi/query-core`, the framework-agnostic keystone the bindings already
depend on, and are exported for the bindings to re-export:

    nameOf(stitch)            — the stable, human-meaningful first key segment
    keyInputFor(input)        — the sanitised input copy (secret headers/params redacted)
    deriveQueryKey(s, input)  — [nameOf(s), keyInputFor(input)]
    StitchQueryOptions<T>     — the TanStack-shaped options type
    stitchQueryOptions(...)   — the overloaded helper

Net effect: about 715 lines deleted from the bindings against ~166 added in query-core.

**The secret denylist is no longer a copy either.** `keyInputFor` now calls core's exported
`isSecretKey` predicate instead of each binding maintaining its own hard-coded header list. A
secret stem registered with `registerSecretKey` is now honoured by every binding's query key, which
was never true of the copies — this is the drift that mattered most, since a missed stem means a
credential lands in a TanStack cache key.

**Fix — react and angular no longer accept-and-ignore a streaming flag.** `UseStitchOptions` and
`InjectStitchOptions` extended the core options INCLUDING `streaming`, then never read it: setting
it compiled and silently did nothing. Both now `Omit` it, so the mistake is a type error.

**P9 — angular `InjectInput` → `MaybeSignal`.** The type describes "a value, a signal, or a thunk",
which is not an input and not angular-specific; `MaybeSignal` says what it is.

Hard break, no aliases — pre-GA, per P19 as amended in #470.

NOTE on the contract ratchet: it reports "5 known, baselined" here rather than 0 because #501's
branch predates #507, which cleared those five R6 entries on main. The baseline file is UNCHANGED
by this commit — no new violations. It returns to 0 once #501 catches up with main.

Verified: workspace typecheck clean, full workspace test suite green, check:exports green for
every package + companion, contract ratchet introduces nothing new, docs-links 110 routes OK,
prettier clean, apps/docs production build passes.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
rejifald added a commit that referenced this pull request Aug 4, 2026
`claude/download-consolidate` was cut 2026-07-06 with a 2026-07-23 merge base.
Main has since landed the pre-GA breaking sweep, so replaying the seven commits
was only half the work — the other half is what the ratchets then demanded.

Core

- `download`'s `interpret` composes `verdictOf(res, cfg)` (ADR 0022 Decision 4)
  and yields `data`, not `value`. The M1 complete-body rule sits AFTER the
  composed verdict, as the surface's own, and now reads `verdict.accept`:
  `classifyStatus` consults it only at `>= 400`, so without that read a caller
  who declared `206` NORMAL would still be rejected — the surface overriding an
  explicit declaration rather than ruling where none was made. Second test in
  download-stray-206.spec.ts pins it.
- `rebuildError`'s cause-carry lands on top of main's body/url arm instead of
  replacing it.

Test rig + specs

- `throttle-host-pooling` keeps the deterministic manual-clock rewrite (the
  wall-clock flake fix) minus its `scope`-alias half — #490 removed the alias.
  ADR 0023 Decision 1 ratified exactly the 500ms in-process spacing it asserts.
- Rig duration fields drop the `Ms` suffix (D3/P17), matching main's own
  `stream.chunkDelay`: `ttfbDelayMs` → `ttfbDelay`, `chunkDelayMs` → `chunkDelay`.
  The rig keeps main's `retryAfterSeconds` (the header's native unit) over the
  branch's `retryAfter`; four existing specs already depend on it.
- Spec config: auth moved to `stitchapi/auth` (ADR 0021) and `apiKey` takes
  `secret` (#580); `retry.baseMs`/`baseDelay` fold into `retry.backoff.base`
  (#513); `respectRetryAfter` → `retry.respect` (#608); `AdapterProgress.phase`
  → `direction`.

@stitchapi/download

- `DownloadBatch.cancel`/`cancelAll` fold into `cancel(id?)` — R8 landed after
  this branch was cut and flags the shared prefix (P24). For a verb the optional
  parameter IS the collapse; an envelope would be the same pair one level deeper.
- `hooks` is built unannotated so `onError` infers REQUIRED: the slot is
  `AtLeastOne<Hooks>` now (P20, #507), which an all-optional annotation cannot
  satisfy.
- Version + vitest to the rc.7 lockstep; lockfile regenerated from main's.
- classify.ts and the README no longer claim the engine drops the transport
  cause — this branch's own core fix carries it through. The hook seam stays:
  it is strictly wider (it also catches a thrown non-`Error`).

Verify: 40/40 workspace projects green (core 1460 passed, download 15/15);
check:lint / types / types-d / exports / exports:companions / contract /
unknown-keys / release / docs-links / format all clean; core size 23.45 gzip
(budget 23.50) and 20.87 for `import { stitch }` (20.90), @stitchapi/download
2.45/2.65 KB; `yakir check` both tiers 0 drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rejifald added a commit that referenced this pull request Aug 4, 2026
`claude/download-consolidate` was cut 2026-07-06 with a 2026-07-23 merge base.
Main has since landed the pre-GA breaking sweep, so replaying the seven commits
was only half the work — the other half is what the ratchets then demanded.

Core

- `download`'s `interpret` composes `verdictOf(res, cfg)` (ADR 0022 Decision 4)
  and yields `data`, not `value`. The M1 complete-body rule sits AFTER the
  composed verdict, as the surface's own, and now reads `verdict.accept`:
  `classifyStatus` consults it only at `>= 400`, so without that read a caller
  who declared `206` NORMAL would still be rejected — the surface overriding an
  explicit declaration rather than ruling where none was made. Second test in
  download-stray-206.spec.ts pins it.
- `rebuildError`'s cause-carry lands on top of main's body/url arm instead of
  replacing it.

Test rig + specs

- `throttle-host-pooling` keeps the deterministic manual-clock rewrite (the
  wall-clock flake fix) minus its `scope`-alias half — #490 removed the alias.
  ADR 0023 Decision 1 ratified exactly the 500ms in-process spacing it asserts.
- Rig duration fields drop the `Ms` suffix (D3/P17), matching main's own
  `stream.chunkDelay`: `ttfbDelayMs` → `ttfbDelay`, `chunkDelayMs` → `chunkDelay`.
  The rig keeps main's `retryAfterSeconds` (the header's native unit) over the
  branch's `retryAfter`; four existing specs already depend on it.
- Spec config: auth moved to `stitchapi/auth` (ADR 0021) and `apiKey` takes
  `secret` (#580); `retry.baseMs`/`baseDelay` fold into `retry.backoff.base`
  (#513); `respectRetryAfter` → `retry.respect` (#608); `AdapterProgress.phase`
  → `direction`.

@stitchapi/download

- `DownloadBatch.cancel`/`cancelAll` fold into `cancel(id?)` — R8 landed after
  this branch was cut and flags the shared prefix (P24). For a verb the optional
  parameter IS the collapse; an envelope would be the same pair one level deeper.
- `hooks` is built unannotated so `onError` infers REQUIRED: the slot is
  `AtLeastOne<Hooks>` now (P20, #507), which an all-optional annotation cannot
  satisfy.
- Version + vitest to the rc.7 lockstep; lockfile regenerated from main's.
- classify.ts and the README no longer claim the engine drops the transport
  cause — this branch's own core fix carries it through. The hook seam stays:
  it is strictly wider (it also catches a thrown non-`Error`).

Verify: 40/40 workspace projects green (core 1460 passed, download 15/15);
check:lint / types / types-d / exports / exports:companions / contract /
unknown-keys / release / docs-links / format all clean; core size 23.45 gzip
(budget 23.50) and 20.87 for `import { stitch }` (20.90), @stitchapi/download
2.45/2.65 KB; `yakir check` both tiers 0 drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant