refactor(spec): strip internal tracker ids from customer-facing zod refusal messages, and gate their return - #12527
Conversation
…usal messages 15 refusal messages across 9 files under packages/spec/src rendered a tracker id verbatim to an author running `os validate` / hitting a parse refusal. The reader of those strings cannot open the tracker, so the id is a citation-shaped token resolving to nothing. Where the reference is genuinely load-bearing for an internal reader it moves to an adjacent // comment (4 sites); elsewhere it is removed outright — git history keeps the anchor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy
…sages Rule 3 in check-doc-authoring: an AST scan of packages/spec/src for internal issue ids in customer-facing zod refusal messages, inheriting the same ruling Rule 2 applies to the published skill catalog. It parses rather than scanning lines because refusal prose here is written as multi-line string concatenation: the commissioning card's own single-line census command found 1 of the 16 literals in the population. Recognises both the `message:` property and the positional validator-message argument, and asserts it saw message strings at all so a blinded detector reds instead of reporting a clean population it never located. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy
Three twins asserted the id as a bare toContain('#7113' / 'objectui#5933' /
'#5055'). Each now pins the sentence that carries the claim, plus a negative
pin that the message contains no issue id at all.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 73a5d2835c18a4c2ed6d5252111f288e8d214016 && git checkout 73a5d2835c18a4c2ed6d5252111f288e8d214016
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 14b114553c3e7c61672e3d25a1d15036f7561e27 e8ded0d2e261f7a841d8d3058afa83003341b37c && git checkout -B drift-repro 14b114553c3e7c61672e3d25a1d15036f7561e27 && git merge --no-ff e8ded0d2e261f7a841d8d3058afa83003341b37c
node scripts/docs-audit/affected-docs.mjs --json 14b114553c3e7c61672e3d25a1d15036f7561e27
|
Fixes #12124
Customer-facing zod refusal messages in
packages/spec/srcended sentences with internal tracker ids. Those strings are printed at the author, verbatim, the moment their metadata is refused — byos validate, by a publish gate, by a parse — and that reader has no tracker to open. Per the triage of 2026-08-25 this inherits the #11052 skills-corpus ruling rather than re-deciding it, resting on the standing maintainer ruling of 2026-08-12, quoted verbatim and untranslated: 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」Census — the card's own command undercounts by 15 of 16
The dispatched census command is single-line. Refusal prose in this tree is written as multi-line
'a ' + 'b ' + 'c'chains, somessage:and the id it carries land on different lines:Control (required before believing the total): both exemplars the card names are found —
data/filter.zod.ts:1454, and theui/action.zod.tsmessage blocks. Note the card estimated ~10 ids inaction.zod.tsmessages; the measured number in message strings is 4 (across 3 messages). The otheraction.zod.tsids are in TSDoc comments, which are not this population.The census buckets every id-bearing string literal under
packages/spec/srcby syntactic position, so the population is named against a total rather than asserted:message(message:+ positional validator messages)strictObject(guidance/history/aliases/retiredForms)tombstone(retiredKey(...),*_RETIRED_KEY_GUIDANCE)describe(docs prose)The three adjacent buckets are the same audience and were deliberately not taken — each is larger than this whole population, each has its own class-wide pins, and AGENTS.md positively asks a tombstone prescription to carry a durable reference. Widening is a separate convention decision, filed as #12522 with the measurements. Rule 3's header carries the same note, so the scope choice is visible where the next author will stand.
Per-member disposition — 16 literals, 15 messages, 9 files
comment= the reference was load-bearing for an internal reader and moved to an adjacent//comment.strip= removed outright; git history keeps the anchor.ai/skill.zod.ts:162#7113...the contract never declared that spelling.ai/skill.zod.ts:177#7113...to test membership of that list.api/endpoint-publish-gate.ts:366#5040 §7-3...before it can be served; call the third-party system from a flow insteadapi/endpoint-publish-gate.ts:566#5040 §3.3`cacheTtl` is GET-only: it becomes a `Cache-Control` header...data/filter.zod.ts:1454#7711...field names, never $-prefixed operators.integration/connector.zod.ts:938#7990...the credential would land in cleartext.(ADR-0097 §3 already carried in the same message)kernel/functional-completeness.ts:132cloud#687This is the shape ADR-0078 was written for.kernel/functional-completeness.ts:281#3196...`webhook.zod.ts` records that the `api` trigger was removed...(the message names the file itself)system/auth-config.zod.ts:420#11739...(declaring member_default explicitly is allowed).ui/action.zod.ts:1513#7278...one condition, one wording, one dialog.ui/action.zod.ts:1567objectui#5933...under the interim precedence the declared onSuccess wins...ui/action.zod.ts:1569#11519...is refused at authoring time.(adjacent comment already carried it)ui/action.zod.ts:1605#11842...not this pair.(adjacent comment already carried it)ui/view.zod.ts:530#5869...refuses it too (400 INVALID_FILTER).ui/view.zod.ts:544#5869...refuses it too (400 INVALID_FILTER).ui/i18n.zod.ts:198#5055...the retired key-reference form: nothing looks the key up...11 strip · 4 comment-kept · 1 strip-with-customer-resolvable-anchor-kept. The two
view.zod.tssites keep400 INVALID_FILTER: that error code is what an author can actually match their query-path failure against, which is the traceability the id was standing in for.Escape hatch: no member is machine-load-bearing
The triage reserves re-entry to the decision box for an id that is load-bearing for a machine consumer rather than prose. Checked and none is:
packages/arecheck:cli-command-ids-adjacent template gates that refuse bare issue numbers in scaffolded comments — the same convention, pointing the same way.runtime/endpoint-executor.ts'shintcarries#5040 §7-3and is asserted byruntime/endpoint-executor.test.ts. It is a runtime sibling of the spec message, outside this card'spackages/spec/srcpopulation, so it is untouched and its pin still passes. Worth a follow-up decision alongside [finding] Three ADJACENT customer-facing text populations in packages/spec still carry tracker ids — 539 literals the refusal-message strip deliberately did not touch #12522.Test twins
Five pins asserted the old wording. Each now pins the sentence carrying the claim, plus a negative pin that the message contains no issue id at all:
ui/view-filter-rule-value-shape.test.ts—'400 INVALID_FILTER, #5869'->'400 INVALID_FILTER'+ negative pinai/skill-trigger-condition-value-shape.test.ts—toContain('#7113')->'the contract never declared that spelling'+ negative pinui/action-doubled-redirect.test.ts—toContain('objectui#5933')->'interim precedence'+'silently ignored'+ negative pinui/i18n.test.ts—toContain('#5055')->'the retired key-reference form'+ negative pinThree of those four were invisible to a fragment grep (they assert the bare id) and were found by running the suite, not by searching. Worth recording: the search-then-edit route would have shipped three red pins.
The gate — Rule 3 in
check:doc-authoringExtended the existing corpus gate rather than adding a new one: it already owns this convention (Rule 2 holds it over
skills/**), already owns the precision-tuned id matcher — the trailing lookahead that keeps CSS hex colours out is load-bearing and is reused rather than re-typed — and is already a step in the requiredLint & Repo Gatesjob, so no new CI wiring can go dormant.It parses rather than scanning lines, for the reason the census shows: a line rule would have been a gate that runs, passes, and is structurally unable to reach 15/16 of its subject.
parseSourceFilefromscripts/ts-parse.mjsis the sanctioned entry (check:parse-guardreds on a rawts.createSourceFileelsewhere underscripts/). It climbs out of+concatenation, template spans, parens and conditionals before asking what position a string occupies, and recognises two spellings — themessage:property and a positional validator message (.regex(RE, 'msg')), the second of which one founding member is written in.It asserts it is not blind. This population's steady state is empty, so "0 violations" and "the detector no longer matches how messages are spelled" are the same line. The gate reports how many message strings it recognised at all and reds on zero:
No exemption list, by design — same as Rule 2.
check:ratchet-remedy-authoritysweeps for exactly that shape and is green.Self-test (
node scripts/check-doc-authoring.mjs --self-test)A red/green pair per claim over a real temporary tree: an id planted on a later line of a concatenated message is red (and the same fixture is asserted invisible to the single-line grep, so this PR's central claim is a measurement in the suite rather than a recollection); a template chain is red; a positional validator message is red; the
repo#NNNNcross-repo spelling is red; a.default()value and a.describe()do not fire; test bodies are out of scope; a tree with no recognised message reportsseen=0; an empty root is a hard error. Removing each planted id returns green from the same scan.Reverse verification on the real tree
Direction fixed before running (red, naming file and id — not "more diagnostics", not a reversal):
The first attempt at that mutation was a silent no-op —
perl -0pi -einterpolated$-as a special variable and exited 0 with the file unchanged. The occurrence-count check caught it; the reading was discarded rather than quietly re-run. Recorded because the healthy-looking output of that first run is exactly what a fabricated ablation looks like.One declaration pin correctly went stale
scripts/pm/dispatch-gates.mjspinned thatcheck:doc-authoring"claims nothing underpackages/". Rule 3 makes that claim legitimately true ofpackages/spec/src, so the pin was updated to assert the new, real population — the gate reachespackages/spec/src, and still claims nothing elsewhere underpackages/nor spec outside its source tree.check:pm-dispatch-gates: 719 cases pass. Verified from the other side too:dispatch-gatesnow namescheck:doc-authoringfor apackages/spec/srcchange, matched via that root.Sweep discipline
16 files: the 9 census-population sources, the 4 test twins (one file overlaps), the gate, its declaration pin, and the changeset. Zero changes outside the census population plus the gate. Re-running the census after the edits shows
message= 0 with the three adjacent buckets at 181 / 176 / 182, unchanged.H17 hold respected:
ui/view.zod.tswas edited at lines 530 and 544 only, far outside the pdf-enum region (~1326-1383); the #8346 hold is named and unaffected — a prose-only id strip moves none of its options.runtime/http-dispatcher.tsuntouched.Verification — all at
e8ded0d, the final commitpnpm --filter @objectstack/spec testTest Files 431 passed | 1 skipped (432)·Tests 11462 passed | 1 skipped (11463)pnpm --filter @objectstack/spec typecheckcheck:test-typecheckundertsconfig.test.json, so the edited test files really are compiledpnpm --filter @objectstack/spec check:generated✓ All 14 generated artifacts are up to datecheck:doc-authoring✓ 531 refusal-message string(s) across 648 spec sources cleanGate list derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(script-derived change set, three-dot semantics) rather than from the dispatch brief, then run.check:generatedwas re-run after a rebuild because it first refused to answer —distpredated a later test edit — which is the gate declining a false green, not staleness in the artifacts.Verdicts are read from each gate's own printed line, with exit codes captured before any pipe.
Two gate names in the brief's neighbourhood do not exist (
check:changeset-fixed,check:changeset-no-major— pnpm answersCommand not found, exit 254). Recorded as not measured, not as failures; the wired changeset gates (check:empty-changeset,check:changeset-gate-self-tests,check:objectui-changeset) are green.check:doc-formula-expressionsfirst reportedPREREQUISITE NOT METtwice (unbuilt@objectstack/formula, then@objectstack/lint); both were built and it then measured green.Changeset
@objectstack/specpatch, following the #12439 precedent for message-prose in a published package: before/after, and an explicit statement that no accept/reject behaviour changes — the same inputs are refused on the same schemas with the same issuecode,pathand error shape. Only the sentence changes.check:adr-0087-registration: no declared-breaking changeset.No governed surface is touched — today's register is
docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.md, and this diff hits none of them.Generated by Claude Code