Skip to content

fix(spec): every defineStack refusal carries an ADR-0112 envelope — six STACK_* codes beside STACK_CROSS_REFERENCE_INVALID - #16342

Draft
huangyiirene wants to merge 5 commits into
mainfrom
claude/issue-15963-definestack-refusal-envelopes
Draft

fix(spec): every defineStack refusal carries an ADR-0112 envelope — six STACK_* codes beside STACK_CROSS_REFERENCE_INVALID#16342
huangyiirene wants to merge 5 commits into
mainfrom
claude/issue-15963-definestack-refusal-envelopes

Conversation

@huangyiirene

@huangyiirene huangyiirene commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes #15963

Clause-②: yes

What changed

defineStack has seven refusal sites. After #14552 one of them — the cross-reference refusal — carried an ADR-0112 envelope (code / status); the other six still threw a bare Error with both undefined. A consumer that had learned to branch on error.code read undefined from six of the seven, which reads as "not a validation refusal" rather than "a refusal with no code yet". Every site now throws a module-local envelope class sharing one StackRefusalError base: status: 422, one code per site, the findings the site collected on issues. Message text is byte-for-byte unchanged at every site — the pins in stack.test.ts, stack-requires.test.ts and the objectql matrix test read the same prose they always did.

site (line at e6f7ccb54a) raiser code envelope before
:2725 ObjectStackDefinitionSchema.safeParse (formatZodError) STACK_SCHEMA_INVALID bare Error
:2741 validateKnownCapabilities STACK_CAPABILITY_UNKNOWN bare Error
:2751 validateCrossReferences STACK_CROSS_REFERENCE_INVALID envelope (#14552, unchanged)
:2760 validateNamespacePrefix STACK_NAMESPACE_PREFIX_INVALID bare Error
:2767 validateSingleApp STACK_SINGLE_APP_VIOLATION bare Error
:2774 validateHierarchyScopeCapability STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED bare Error
:2781 validateTriggerCapability STACK_TRIGGER_CAPABILITY_REQUIRED bare Error

One code per site, never a shared STACK_VALIDATION_FAILED: the dispatcher vocabulary's boot-refusal class was already at one-row-per-refusal granularity (14 rows), and STACK_CROSS_REFERENCE_INVALID is an instance of that granularity, not an exception — executing the answered decision, not re-opening it. Spellings follow the ledger's own suffix vocabulary, measured over ERROR_CODE_LEDGER: _INVALID (7 members), _REQUIRED (11, e.g. TENANT_SCOPE_REQUIRED), _UNKNOWN (FILTER_TOKEN_UNKNOWN), _VIOLATION (UNIQUE_VIOLATION, EXTERNAL_SCHEMA_MODE_VIOLATION).

StackCrossReferenceError keeps its code, name and message and now extends the same base; nothing is exported (the module is re-exported with export *, so check:api-surface is unchanged — verified against a fresh dist). No class is named ValidationError: validationFailureDetails in @objectstack/types duck-types a record-validation failure on that name and would answer 400 VALIDATION_FAILED + fields[]; the base class documents the trap and the new test pins it. issues is heterogeneous by design — one string per finding on the six semantic refusals, the zod issue objects on the schema arm — and the base-class docblock says so (review advisory A2).

packages/runtime/src/dispatcher-error-vocabulary.ts carries one classification row per new code (shape: 'classfield', door: 'none', verdict: 'boot-refusal'), with the reachability measurement re-taken on this tree rather than inherited.

Readings — re-measured on this branch, not inherited

  • Base: fast-forwarded onto origin/main 6c546ab9d0 before any edit, so feat(spec): register NAMESPACE_CONFLICT in ERROR_CODE_LEDGER #16252 (which removed the NAMESPACE_CONFLICT pending row, 42 lines) is under every measurement. At that base the seven sites sat at :2553 :2566 :2576 :2584 :2591 :2598 :2605 and the class at :1792 — the dispatch's numbers held.
  • Live control readonly code = in stack.zod.ts: 1 → 7; files carrying it across packages/spec/src: 3 → 3 (connector-provider-errors.ts, external-errors.ts, stack.zod.ts). Bare throw new Error( inside defineStack: 6 → 0.
  • Ledger: verdict: 'boot-refusal' rows 14 → 20; STACK_* rows 1 → 7; the gate's census 65 → 71 classified sites, still 2 awaiting a ledger entry.
  • Reachability: non-test defineStack occurrences under packages/runtime/src + packages/rest/src = 33, every one a docstring, a comment or the vocabulary table's own prose — zero call sites (the precedent row recorded 25 on its tree).
  • The CLI reads .code on none of these throws today (packages/cli/src measured): the card's consumer is the branch this change makes possible, not one that exists.

The :2553 judgement — its own arm, STACK_SCHEMA_INVALID (reading taken before writing)

The schema site is an aggregate of zod issues against the schema the stack declares, not a rule evaluated on a parsed stack, so it was judged on its own rather than copied. Four options, each along the four axes:

  • A (chosen) — own arm STACK_SCHEMA_INVALID, 422, issues = the zod issues structurally. 长远: one closed shape for all seven refusals, and the shape the tree already uses for "an entity contradicts the schema it declares" — the ledger's two zod-shaped refusals are both spelled *_SCHEMA_INVALID: METADATA_SCHEMA_INVALID (SchemaValidationError in @objectstack/metadata-core, the issues-carrying precedent — its docstring says 422 but nothing in the tree assigns it a status, per review advisory A1) and FLOW_INPUT_SCHEMA_INVALID (answered 422 by packages/runtime/src/flow-dispatch-status.ts); the zod-shaped refusal metadata-protocol actually stamps at 422 is INVALID_METADATA (protocol.ts, runtime-authoring-gate.ts). So the issues shape comes from the first and the 422 from the other two. 防 AI 写错: structurally distinct from every other channel; undefined is no longer a legal answer at any site. 业务: the population is os validate / os build / host boot and the AI author — skills/objectstack-platform/SKILL.md line 89 shows this exact header to authors. 创业阶段: one class, no new mechanism, no export, no transition.
  • B — reuse spec's "existing zod-failure channel". Measured: there is none. formatZodError / safeParsePretty return prose; every extends Error in packages/spec/src is a domain refusal and none wraps a ZodError. The only existing channel is the message — the defect itself.
  • C — 400 VALIDATION_ERROR (status-derived, no own code). That is the request-syntax bucket (packages/rest answers a malformed body with it, passing raw zod issue codes as field errors — ADR-0112 D6). defineStack serves no request, and a bare status with no code leaves the throw's code undefined — the exact shape the card measured.
  • D — VALIDATION_FAILED + fields[] via zodIssuesToFields. objectql's record-validation vocabulary, recognised by duck-typing on code or name === 'ValidationError'. An authored stack would be indistinguishable from a refused record write to every reader of the dispatcher's envelope — the conflation an error.code exists to prevent, and the 防 AI 写错 axis rules it out on its own.

issues carries the zod issues (path, code, message per entry) rather than the formatted lines: the message already renders those, SchemaValidationError.issues carries the same, and resolveThrownHttpError already reads a thrown .issues array as spec-validation context. The header's count is issues.length either way, which the test pins.

Changeset — @objectstack/spec minor, @objectstack/runtime minor

Raised from the patch the #14552 precedent took, because the level is required, not chosen: six new STACK_* spellings ship in packages/spec/dist/index.js and dist/index.mjs, nothing reaches .d.ts, and once shipped a consumer's catch (e) { switch (e.code) … } depends on them and they cannot be renamed. That is a purely additive widening of a published package's public surface, and the maintainer ruling of 2026-09-04 (decision batch #35, on #15294) requires at least minor for it — the commit type may raise a bump but never lower it below what the act requires. The patch precedent is pre-rule. Check Changeset enforces this on any PR that declares clause-② yes, which is what the Clause-②: yes line at the top of this body declares durably, independent of the carrier label.

Verification (gate verdict lines, exit codes read after redirect, never through a pipe)

Tests, at 48e462172f (spec sources byte-identical at 273247e56f; the follow-up e6f7ccb54a changes spec only inside docblocks — 0 non-comment lines in its stack.zod.ts diff — so no spec rebuild was owed for it):

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 over stack-refusal-envelopes.test.ts, stack-cross-reference-envelope.test.ts, stack-requires.test.ts, stack.test.tsTest Files 4 passed (4), Tests 149 passed (149); the new file alone 27 passed (27).
  • pnpm --filter @objectstack/objectql exec vitest run src/registry-cross-package-item-classes.test.ts (the consumer pinning the precedent envelope, read through a freshly built @objectstack/spec dist) — 14 passed (14).
  • pnpm --filter @objectstack/spec typecheckVERDICT command-exit 0 (tsc, check:scripts-typecheck, check:test-typecheck: OK — @objectstack/spec's test layer compiles, no debt-ledger change).
  • Runtime typecheck, declared narrowing: the full closure is 25 workspace packages; the edited file has 0 imports, so an isolated tsc -p over exactly that file (program = 1 runtime source, --listFiles) exits 0. CI runs the full program.

Gates, on 273247e56f and re-run on e6f7ccb54a:

  • pnpm check:dispatcher-error-vocabularyOK — 71 unregistered code-stamping site(s), all classified; 2 awaiting a ledger entry.
  • pnpm check:doc-authoringsibling-package prose ids hold the baseline — no growth (a first run at 48e462172f was a real red: tracker ids inside the six why strings; fixed in 20556ee3af).
  • pnpm check:error-code-casingno unlisted lowercase error codes in 5706 scanned file(s); pnpm check:nul-bytesOK ... no raw ASCII control bytes.
  • pnpm --filter @objectstack/spec build then check:generatedAll 15 generated artifacts are up to date (check:api-surface, check:authorable-surface, check:docs included).
  • scripts/pm/dispatch-gates.mjs --ran73 derived, 73 run, 0 UNRUN. Two of the 73 returned the prerequisite exit 3 in this partially built worktree and are NOT MEASURED here, owned by CI: check:dual-build-cjs-loads (51 packages without dist/) and check:type-check-debt (needs every closure built).
  • pnpm lint narrowed and proven: eslint over the three changed TS files (--format json: 3 files, 0 errors, 0 warnings); population read from eslint.config.mjs (packages/**/*.{ts,tsx,mts,cts} covers all three); invariance: the config declares no parserOptions.project and no typed rules, so this diff cannot move any untouched file's verdict. The repo-wide scan is CI's.
  • The level axis, driven offline with check-changeset-no-major.mjs --event on e6f7ccb54a (the gate reads the changeset at the committed head): a payload with no label and a body carrying Clause-②: yes✓ LEVEL AXIS: this PR declares clause-② yes, and no package whose packages/*/src/** it moves is graded patch, reading declaration line: Clause-②: yes — the gate fires on the line alone; the same payload on 273247e56f (still patch) → ⛔ ... grades a package it grew patch, exit 1; a payload with neither label nor line → LEVEL AXIS: NOT MEASURED, exit 0 — the blind green, not a pass.

Ablation — direction predicted before each run, restore proven

The gate reads source, so no dist/ leg applies; each mutation is proven on disk by occurrence counts, restored with git checkout HEAD -- path, and the restore proven by blob hash equal to HEAD's plus an empty git diff HEAD.

  • A — delete the STACK_TRIGGER_CAPABILITY_REQUIRED ledger row (rows 1 → 0, 17 lines). Predicted: red, naming an unclassified stamping site. Observed: exit 1, [unclassified-site] packages/spec/src/stack.zod.ts stamps unregistered code 'STACK_TRIGGER_CAPABILITY_REQUIRED' (classfield) and packages/runtime/src/dispatcher-error-vocabulary.ts does not classify it. Restored: blob ed927db5… = HEAD.
  • B — replace one class's literal stamp with an unresolvable constant (literal stamps 1 → 0). Predicted: red in the other direction, a stale row. Observed: exit 1, [stale-row] ... declares 'STACK_SINGLE_APP_VIOLATION' at packages/spec/src/stack.zod.ts (classfield) but the scan no longer finds it — plus a second diagnostic I had not predicted, [unresolved-constant] on the marker. Restored: blob 8d26555e… = HEAD.

Scope notes

  • composeStacks in the same file still carries bare refusals (the action-key collision at its tail and the object-source internal error). My judgement: the action-key collision is the same class as these seven (an authored entity refused at authoring, reachable through the same callers) and belongs in this family under its own STACK_* code; the object-source line is an internal invariant (composeStacks internal error), not an authored-entity refusal, and would be mis-filed at 422. Not folded in — reported for a card.
  • Five sentences still call ManifestSchema an open object after #14192 closed it — stack.zod.ts, compile.ts, core artifact-packages.ts, the strictness ledger's own prose, and the main describe's loose ADR-0025 citation #14721 is not addressed here. Its docblock sentence on AssembledPackageBodySchema is untouched, and nothing in this diff changes whether that sentence is true.
  • origin/main is merged in (textual merges, no conflicts): 3e270d4e29 before the PR opened, and again after this body edit so the Check Changeset gate — which runs on synchronize, not on edited — re-reads the body carrying the Clause-②: yes line.

Carrier discipline

Draft, dispatched at CONTRACT_REVIEW_TIER; the review passed at tier with no blockers and the PM cleared needs:contract-review from this PR afterwards, which is correct carrier discipline — the durable clause-② declaration is the line at the top of this body, not the label. This PR is not flipped to ready and not enqueued.

…ix STACK_* codes beside STACK_CROSS_REFERENCE_INVALID

The six remaining bare-Error refusal sites in defineStack (schema parse,
capability, namespace-prefix, single-app, hierarchy-scope capability,
trigger capability) now throw module-local envelope classes sharing a
StackRefusalError base: status 422, one code per site, findings on
issues. Message text is byte-for-byte unchanged at every site.

The schema arm is its own code (STACK_SCHEMA_INVALID) on a reading taken
before writing it: spec has no zod-failure envelope to reuse, the ledger's
two zod-shaped refusals are both *_SCHEMA_INVALID at 422, and the request-
syntax (VALIDATION_ERROR) and record-validation (VALIDATION_FAILED, duck-
typed on name === 'ValidationError') channels would each mis-file an
authored stack.

One classification row per new code in the runtime dispatcher error-code
vocabulary (door none, verdict boot-refusal), with the reachability
measurement re-taken on this tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
…ing prose

check:doc-authoring refuses an issue id inside sibling-package string
prose (a runtime string reaches authors who cannot resolve #NNNN); the
ADR anchor stays, the tracker ids move out of the strings. The comment
header above the rows keeps its id — comments are the reader who can.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

15 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: defineStack (symbol, 59 pages)
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 135 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a87163c89b96b57fa4d1002b63fa9e77d039d01bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 05cae3b99a818bf79eb25037443adb9e36db4d91 — the merge of head ce694a09eff2ee55b28f616a0bdb1054e7eea796 into base a87163c89b96b57fa4d1002b63fa9e77d039d01b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 05cae3b99a818bf79eb25037443adb9e36db4d91 && git checkout 05cae3b99a818bf79eb25037443adb9e36db4d91
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a87163c89b96b57fa4d1002b63fa9e77d039d01b ce694a09eff2ee55b28f616a0bdb1054e7eea796 && git checkout -B drift-repro a87163c89b96b57fa4d1002b63fa9e77d039d01b && git merge --no-ff ce694a09eff2ee55b28f616a0bdb1054e7eea796

node scripts/docs-audit/affected-docs.mjs --json a87163c89b96b57fa4d1002b63fa9e77d039d01b

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

…422 claim on METADATA_SCHEMA_INVALID

Clause-② is yes (six new error codes ship in spec's dist and cannot be
renamed once consumers branch on them), and a purely additive widening
of a published package's public surface takes at least minor — the
commit type may raise a bump but never lower it below what the act
requires (maintainer ruling 2026-09-04, decision batch #35). Both
packages move from patch to minor; the changeset records why.

Review advisory A1: nothing in the tree assigns METADATA_SCHEMA_INVALID
a status — it stays the issues-carrying precedent, FLOW_INPUT_SCHEMA_INVALID
carries the 422 (flow-dispatch-status.ts), and the zod-shaped refusal
metadata-protocol stamps at 422 is INVALID_METADATA. A2: the base class
docblock states that issues is heterogeneous by design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants