Skip to content

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

Open
huangyiirene wants to merge 7 commits into
mainfrom
claude/issue-15963-definestack-refusal-envelopes
Open

fix(spec): every defineStack refusal carries an ADR-0112 envelope — six STACK_* codes beside STACK_CROSS_REFERENCE_INVALID#16342
huangyiirene wants to merge 7 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 60ff091a99287003b22a4138db9cdee3b9f927aapackageMentionDocs.

Which tree this was computed on

This run read content/docs from 680887ab3f1490a4ae616b5b4578bc051eabe35d — the merge of head da4d62b154f8a492b17695e97315de19816a9104 into base 60ff091a99287003b22a4138db9cdee3b9f927aa, 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 680887ab3f1490a4ae616b5b4578bc051eabe35d && git checkout 680887ab3f1490a4ae616b5b4578bc051eabe35d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 60ff091a99287003b22a4138db9cdee3b9f927aa da4d62b154f8a492b17695e97315de19816a9104 && git checkout -B drift-repro 60ff091a99287003b22a4138db9cdee3b9f927aa && git merge --no-ff da4d62b154f8a492b17695e97315de19816a9104

node scripts/docs-audit/affected-docs.mjs --json 60ff091a99287003b22a4138db9cdee3b9f927aa

⚠️ 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
@huangyiirene
huangyiirene marked this pull request as ready for review September 6, 2026 19:24

Copy link
Copy Markdown
Collaborator Author

PM — flipped to ready and auto-merge armed at 19:24:12Z. The body's Carrier discipline paragraph ("not flipped to ready and not enqueued") described the state when it was written and is now superseded by this comment; it is not rewritten, so the record of what the dev handed over stays intact.

Why now. The three landing preconditions:

  1. At-tier review PASS holds. The refresh to 6abf1c940c merged origin/main d83d079b4a in with zero conflicts, and the PR's own delta is byte-identical across it — the same 4 files, 587 insertions / 13 deletions, git diff --name-only d83d079b4a..HEAD listing exactly those four. Nothing from main leaked in and nothing of the PR was lost, so the reviewed content did not move.
  2. Carriers clear on both. No needs:contract-review on this PR or on finding(spec): six of defineStack's seven refusals are still bare Errors — no ADR-0112 code/status — leaving one envelope among neighbours that have none #15963; the durable clause-② signal is the Clause-②: yes line at the top of the body, which is what Check Changeset reads. That gate is green.
  3. Required checks — delegated to the mechanism, not asserted by me. This PR's only red was Test Core, and its cause was Test Core (5/6) killed at its 30-minute wall (cancelled 30:17 in run 34042090143) with every other shard and gate green — the CI: the shard-timings file is stale for the CLI package — 672s predicted vs 28m46s measured against a 30-minute timeout, so Test Core shard 1/6 is one slow run from being killed on any PR touching the CLI #16173 coin toss, not this PR's content. Arming auto-merge lets branch protection enforce green mechanically instead of me eyeballing it; the PR cannot enter the queue until it actually passes.

None of the four files touch the governed surface, so this is not a governed-surface merge.

On the refresh itself: it was a merge, never a rebase, amend or force-push — the push was the fast-forward ce694a09ef..6abf1c940c, so anyone holding the old head keeps a valid checkout. Draft state aside, the changeset levels, body, labels, assignee and claim comments were all left untouched, and no CI shard was re-run.

If Test Core comes back red on 6abf1c940c, the first thing to read is whether 5/6 was cancelled at ~30:1x. That is a lost toss and gets re-queued; anything else is this PR's to root-cause.


Generated by Claude Code

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