Repository navigation
Commit d7631d5
fix(spec): a refusing defineStack / composeStacks carries the ADR-0087 conversions it applied on its refusal — stackConversionsOf(error) reads them (#20651)
Fixes #20618
Clause-②: no
## What this lands
This is the `packages/spec` half of #20583 (its location 2). #20583
keeps the CLI fold in the three catch-alls; this PR does not touch
`packages/cli`.
A `defineStack` call that converts an old spelling and then refuses now
carries the conversions it applied on the ADR-0112 refusal it throws.
The record uses the same `Symbol.for('objectstack.stack.conversions')`
key and the same properties as the record on a built stack: a frozen
`ConversionNotice[]`, non-enumerable, non-writable, non-configurable. It
is the producer's own array as it stands at the throw. There is no
second conversion pass, and nothing reads the warn-once stderr line.
- **`stack.zod.ts`**: `defineStack` is now a thin wrapper around its
unchanged body (`buildDefinedStack`). The wrapper holds the
`appliedConversions` array that the conversion pass pushes to, and its
one `catch` stamps that array on any `StackRefusalError` before it
rethrows the same object. `composeStacks` gets the same wrapper, and its
record formula moves into one helper (`composedConversions`) shared by
the return and the refusal. One rule for which throw is stamped
(`withRefusalConversions`): members of the `StackRefusalError` family
only. Anything else is rethrown untouched.
- **`stack-provenance.ts`**: `markRefusalConversions` is the refusing
half of `markStackProvenance`: same writer, no mark, first stamp wins.
It is module-internal and not re-exported. `stackConversionsOf` reads
the record off a marked stack, as before, **or** off an `Error` that
carries it as an own property. A plain object that carries the key
without the mark is still not read, and neither is a record inherited
through a prototype. The module header gains a section on the refusal
record, and the reader's TSDoc section "What it cannot hold" is amended:
the refusing-`defineStack` boundary is gone, and the non-refusal-throw
boundary is stated.
- **One changeset**, `@objectstack/spec: patch`.
Each refusal keeps its `code`, `status`, `name`, message and `issues`
byte-for-byte, and `hasStackProvenance(error)` still answers `false`.
## How a door reads it (for #20583's CLI fold)
```ts
} catch (error) {
conversions.push(...stackConversionsOf(error)); // readonly ConversionNotice[]
```
- The return is frozen. Each element is the whole `ConversionNotice`
(`code`, `conversionId`, `surface`, `from`, `to`, `path`, `toMajor`,
`retiresIn`, `message`), the same element
`LoadedConfig.stackConversions` carries. `path` is relative to the
refusing `defineStack` call.
- It answers `[]` for a refusal whose source needed no conversion, for a
plain `Error`, and for any throw that is not a producer refusal (the
CLI's own "throw at load" fixture is one of these).
- The reader never uses `instanceof` on the refusal class. It keys on
`Symbol.for` plus `instanceof Error`, so a CLI and a config that resolve
two copies of this package still agree, as long as they run in one
realm.
- A door's own step-2 pass never runs on the refusal path, so folding
the record cannot double-count.
## Coverage: every throw between the first conversion and the return
(hypothesis 1, measured by reading)
The conversion pass itself (`normalizeStackInput` into
`applyConversions`) is documented never to throw. After it,
`defineStack` reaches exactly **10 throw sites**, all
`StackRefusalError` subclasses:
- **The 7 in the strict tail:**
- the schema parse (`STACK_SCHEMA_INVALID`);
- capability, cross-reference, namespace prefix, single app,
hierarchy-scope capability and trigger capability.
- **The 3 in `mergeActionsIntoObjects`,** all `STACK_SCHEMA_INVALID`:
`objects` is not an array, an `objects` entry is not an object, and an
`actions` shape is wrong. The merge ends **both** modes, so these 3 are
reachable after a conversion under `strict: false`. This is a refusal
the triage's coverage clause names ("every refusal thrown after a
conversion") that sits outside the strict tail.
The wrapper's single `catch` covers all 10. The census in the tests
drives each site with a converting page and asserts the record: 10 rows,
7 distinct codes.
No non-refusal throw is reachable by construction:
- `warnUnknownAuthoringKeys`, the six `validate*` helpers and
`warnEmailTemplateLocaleFloor` contain no `throw`, directly or through
the modules they call.
- `safeParse` returns its failure instead of throwing it.
The residue is a throw from inside a zod refinement or transform, or
from an author's own getter or proxy. Such a throw is not a producer
refusal, so it is rethrown untouched and carries no record. The TSDoc
states this, and the tests pin it through `composeStacks`' options
parse.
## `composeStacks`: extended in place (the same defect class)
A `composeStacks` refusal is thrown after its inputs' `defineStack`
calls converted, and before this change it carried nothing, which is the
same loss. All four in-place conditions hold:
- it is the same defect class;
- it is a mechanical fix whose shape this card pins (the same stamp, and
the formula PR #20579 pinned for the artifact);
- the file is in this claim's surface, and no other claim holds it;
- it adds no new gate family.
The guard wraps the whole body, so every throw in its call tree passes
through the one `catch`.
- **What is stamped:** 13 refusal sites: 10 in its own call tree
(provenance, the concat shape, the artifact cross-reference, the
action-key collision, the two `mergeObjects` refusals, the two function
conflicts, the key conflict and the collection conflict) plus the 3 in
`mergeActionsIntoObjects`.
- **What is not stamped:** the two non-refusal throws, which are the
`ComposeStacksOptionsSchema.parse` zod error and the internal-invariant
`Error`.
- **Pinned:** the provenance refusal (step 0), an object conflict (step
2), the empty-record control, and the options-parse non-refusal.
The claim's file-surface parenthetical reads "defineStack's strict
tail". The seat may amend it to cover the `strict: false` merge refusals
and `composeStacks`.
## Where a refusing inner `defineStack` surfaces (hypothesis 3,
measured)
In `composeStacks([defineStack(A), defineStack(B)])`, B's refusal is
thrown **while the array literal is being evaluated**, and
`composeStacks` never runs. The test records that only A was built. The
error's record is **B's own** notice: exactly one, not A's object
(checked by identity), and B printed **0** stderr header lines because
the warn-once set already had the key.
## API surface (hypothesis 2)
`stackConversionsOf(value: unknown)` already accepted the error, but its
body returned `[]` for anything without the provenance mark. It is
reused, with one arm added. No export is added or changed:
- `check:api-surface`: exit 0.
- `check:export-origins`: exit 0.
- `check:generated`: all 15 artifacts up to date against a fresh build.
Hence `Clause-②: no` and a `patch` changeset.
## Verification record (HEAD `e6595835ae`)
- **Build:**
- `pnpm --filter @objectstack/spec build`: exit 0.
- After the restart: `turbo run build --filter='./packages/*'
--filter='./packages/*/*'`, 71/71 tasks successful.
- **Spec tests (`--project local`)**, in 4 shards, all passing: 144 +
144 + 144 + 143 files, 4582 + 4133 (1 todo) + 3721 + 4502 tests.
- **Spec tests (`--project repo`):** the 8 repo-project files that
reference the stack producers, 142 tests, passed.
- **Typecheck:** `pnpm --filter @objectstack/spec typecheck` exit 0.
`check:test-typecheck` answered OK, with the debt ledger unchanged.
- **The record test file:** 39 tests, 16 pre-existing and 23 new.
- **Ablation A** (committed state `9deca56296`, through
`scripts/ablation-replace.mjs` in wrap mode with a trap). It deletes the
stamp call in `withRefusalConversions`.
- On disk: anchor x1 to x0, blob `f916adad1fe4` to `8aac6e049c3c`.
- Result: 19 failed and 20 passed. The red rows are every refusal-record
row. The 16 pre-existing rows, the "otherwise the same refusal" row, the
census-count row, the non-refusal row and the plain-`Error` row stayed
green.
- Restore: blob equals HEAD, and `git diff HEAD` is empty.
- **Ablation B** (the same method). It removes the reader's refusal arm.
- On disk: anchor x1 to x0, blob `89278c468c95` to `ed6b5a82b5b3`.
- Result: 17 failed and 22 passed. The two stamped-empty-record pins
stayed green because they read the property descriptor directly.
- Restore proven the same way.
- Both ablations turned red, the expected direction. The tests import
`src/` directly, so no `dist/` was on the measured path.
- **Gates:**
- `dispatch-gates --commands` derived 83 commands, all exit 0.
- `--ran` reconciliation: "83 derived, 83 run, 0 NOT-MEASURED, 0 UNRUN"
(a derived zero).
- Four gates first answered PREREQUISITE NOT MET (exit 3):
`check:doc-formula-expressions`, `check:dual-build-cjs-loads`,
`check:lean-entry-closure` and `check:type-check-debt`. All four were
re-run green after the full build.
- **Lint (a proven narrowing, not the repo run):**
- `eslint --no-inline-config --format json` over the 3 changed `.ts`
files reports 3 files, 0 errors and 0 warnings.
- The population is `eslint.config.mjs`'s `**/*.{ts,…}` block.
- The config has no `parserOptions.project` and no typed rules, so the
diff cannot move any untouched file's verdict.
- `pnpm lint` over the repo is CI's.
- **Full-repo pin sweep:** zero pins of the old semantics. By grep:
- no test deep-equals a stack refusal;
- no test reads a stack refusal's own symbol keys;
- no test asserts that `stackConversionsOf` answers `[]` for a thrown
refusal;
- the boundary prose lived only in `stack-provenance.ts`.
The CLI's "throw at load" control is a plain `Error` thrown before any
producer, and stays `[]` by the new rule. Refusal assertions for illegal
shapes are untouched.
## Acceptance notes
- The `strict: false` merge refusals and `composeStacks` are covered
beyond the claim's "strict tail" wording. The reasons are above.
- **Not stamped, by decision:** non-refusal throws.
- Inside `defineStack`, none is reachable by construction.
- Inside `composeStacks`, two are: the options-parse zod error (an
authored-options mistake) and the internal-invariant `Error`.
- A door therefore answers `conversions: []` for those. Stamping
arbitrary thrown values would hand a producer record to errors the
producer did not construct, including frozen or primitive ones.
- The record on a composed refusal is the whole composition's (all
inputs, in input order), not only the inputs involved in the conflict.
This is the same record the artifact would have carried.
- `origin/main` moved after the merge (`0cb72cfc72` at report time).
None of its commits touch these files, so the branch was not re-merged.
CI's merge ref re-verifies the combination.
---
_Generated by [Claude
Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 19fc8d6 commit d7631d5
4 files changed
Lines changed: 478 additions & 32 deletions
File tree
- .changeset
- packages/spec/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
25 | 31 | | |
26 | 32 | | |
27 | 33 | | |
| |||
276 | 282 | | |
277 | 283 | | |
278 | 284 | | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
0 commit comments