Skip to content

check-dispatcher-error-vocabulary is blind to a non-literal code: in an object literal — a constant or template produces no finding, silently #9223

Closed

Description

@os-project-manager

Found while implementing #9098 (the REST door-typing half, PR #9222). Filed unassigned; nobody is on it. Not fixed there — that card's scope was the door's typing, and widening this gate's scan is a separate, self-contained change with its own self-test obligations.

The gap

scripts/check-dispatcher-error-vocabulary.mjs publishes its recognised spellings in SHAPES, and its own header states the cost precisely:

the price of a source scan is that it sees only the spellings it knows, and an unrecognised one produces no finding, SILENTLY. Reaching for a spelling that is not here? Extend SHAPES and add a --self-test case in the same edit.

Of the four shapes, only one resolves a non-literal value — classconst, which handles readonly code = CONST on a class field. The objlit shape requires a quoted literal:

{ name: 'objlit', re: /\bcode:\s*'([A-Za-z][A-Za-z0-9_]*)'/g, literal: true },

So both of these stamp a wire code and are matched by nothing:

throw Object.assign(new Error(msg), { code: SOME_MODULE_CONST });   // constant in an object literal
sendDeclaredFault(res, { code: `APPROVAL_${action}_FAILED`, ... }); // template-generated

Neither reaches resolveConstant, because that is only called for shapes declared literal: false, and objlit is literal: true. The value is not reported as unresolved either — the site is simply never seen. That is a different outcome from the header's stated bound ("A constant this gate cannot resolve is REPORTED as unresolved, never dropped"), which holds for classconst and not here.

Why it is worth a card rather than a note

This is not hypothetical, and it has already cost a round. #8885 could not lean on this gate for the template-generated APPROVAL_*_FAILED family and had to add a bespoke runtime pin instead — packages/rest/src/rest-approvals-wire-codes.test.ts — to mechanically redden a future approvals route whose generated code goes unregistered. One package now has a hand-written class pin doing the job the repo-wide gate advertises, and no equivalent exists for any other package that generates or indirects a code.

The blind spot is also the quiet kind: adding a producer in either spelling leaves the gate green and prints nothing in its declared bounds, so a partial gate keeps reading as a complete one.

Possible shapes, not a recommendation

  • Extend objlit into a literal: false sibling (code: IDENT in an object literal) so resolveConstant runs on it, with --self-test cases in the same edit as the header demands.
  • Report a template-literal code: as unresolved rather than dropping it — the gate cannot evaluate it, and the header's own rule says an unresolvable value is a finding, never a silent pass.
  • Consider whether rest-approvals-wire-codes.test.ts then becomes redundant, or stays as the runtime half.

Related: #8087 (the gate), #8885 (the approvals workaround), #9098 / PR #9222 (where this was noticed; that PR added a door-typing half to the same script but did not touch SHAPES).


Generated by Claude Code

Activity

  1. added
    pm:queueReady for the PM dispatch loop
    domain:specDomain lane — seat card indexed by label:pm:seat
    on Aug 17, 2026
  2. added theissue type on Aug 17, 2026
  3. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    Contributor

    Triage: lands in scripts/check-dispatcher-error-vocabulary.mjs — the gate orbits the ADR-0112 error-code contract, so domain:spec (machine face; no *.zod.ts / ledger edits expected). Type Bug: the gate's own declared bound ("a constant this gate cannot resolve is REPORTED as unresolved, never dropped") does not hold for the objlit shape — a code: CONST or template-literal code: in an object literal is silently unseen, and #8885 already paid for the gap with a bespoke runtime pin. pm:queue.

    Size/model suggestion: M, opus — extend SHAPES (a literal: false objlit sibling + template-literal-as-unresolved) with --self-test cases in the same edit per the header's own contract; decide in-card whether rest-approvals-wire-codes.test.ts stays as the runtime half. Serial note: PR #9222 touched the same script (door-typing half) — sync with its landed state before editing.


    Generated by Claude Code

  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 17, 2026
  5. self-assigned this
    on Aug 17, 2026
  6. os-steve commented on Aug 17, 2026

    @os-steve
    Collaborator

    Claim: PM loop round 1
    Session: session_01Fs18A2DdXLVN2h8PaaFBcP
    Branch: claude/issue-9223-error-vocab-nonliteral-code
    Worktree: objectstack-issue-9223
    Domain: domain:spec
    File surface: scripts/check-dispatcher-error-vocabulary.mjs (SHAPES + --self-test in the same edit), plus a written disposition (not necessarily an edit) on packages/rest/src/rest-approvals-wire-codes.test.ts (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus — judgment default; this round's node scripts/pm/dispatch-gates.mjs --tier output shows no path-derived mandate and no clause-② suspect path for this surface (gate mechanics, not contract accept/reject behaviour); triage suggestion (M, opus) adopted
    Serial constraints cleared: PR #9222 (same script, door-typing half) MERGED into main at 0668f02 — same-day churn, dev must sync to current origin/main before editing; no open PR touches this script (checked this round); #9228 / #9182 / PR #9191 in-flight surfaces all disjoint. No in-flight sibling's pins assert this gate's behaviour.


    Generated by Claude Code

  7. os-steve commented on Aug 17, 2026

    @os-steve
    Collaborator
    {
      "issue": 9223,
      "status": "done",
      "branch": "claude/issue-9223-error-vocab-nonliteral-code",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9247",
      "premise_still_valid": true,
      "summary": "Closed the gate's own blind spot: `objlit` required a quoted literal, so `code: CONST` and a template-generated `code:` in an object literal matched NOTHING — not reported as unresolved, not reported at all. Added `objlitconst` (resolves the constant, like `classconst`) and `objlittemplate` (reported under a family identity, `${…}` → `*`), with `--self-test` cases in the same edit per the header's own rule; `literal: true|false` became an explicit `resolve: 'literal'|'constant'|'template'`. Two mechanisms the sketch did not name were required by measurement: `resolveConstant` now follows WORKSPACE-package imports (8 of the 15 first-pass findings were resolvable constants behind `@objectstack/*`, scoped per-package and unique-or-nothing so the two-drivers hazard still reports), and a new `runtime-pinned` verdict lets the declaration table classify an interpolated family with evidence — refused on any non-template shape and requiring a `pin:` file that EXISTS. The widening surfaced 6 real sites, all classified; one is a genuine wire hole (see below). Gate green: 13 sites, all classified.",
      "tests": "All at final HEAD 887fc557a. (1) `pnpm check:dispatcher-error-vocabulary` → `--self-test: 6 shapes + 62 assertions OK` (was 4 shapes + 37) and `OK — 13 unregistered code-stamping site(s), all classified; 1 awaiting a ledger entry (#8846)`. (2) `pnpm --filter @objectstack/runtime test` → `Test Files 165 passed (165) / Tests 2464 passed (2464)`. (3) `pnpm --filter @objectstack/runtime typecheck` → clean (tsc --noEmit, exit 0). (4) Re-derived gates for my ACTUAL diff via `node scripts/pm/dispatch-gates.mjs` — beyond the dispatch list it named check:cross-package-test-inputs plus the edits-a-test-file convention family; ran check:nul-bytes, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:error-code-casing, scripts/docs-audit/check-affected-docs.mjs → all OK. `check:type-check-debt --re-measure` needs a full workspace build and is left to CI; no NEW test file was added so its structural half cannot move. (5) Control-char self-scan over all three changed files: no match. (6) REVERSE VERIFICATION, on the real tree, both restored and re-verified green afterwards — direction was red in both, as predicted: adding `{ code: ABLATION_PROBE_CODE }` to `endpoint-policy.ts` produced `[unclassified-site] … 'ABLATION_PROBE_ONE' (objlitconst)`, exit 1, which is precisely the class that produced ZERO output on main; renaming the #8885 pin file away produced `[missing-pin] … but that file does not exist`, exit 1. Baseline on main for contrast: green, 7 sites. No ablation ran against a build artifact, so no rebuild leg applies (this gate reads source, never dist).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #9246: `UNIQUE_SCOPE_CONFIRMATION_REQUIRED` reaches a wire as `error.code` unregistered — the ADR-0120 D5e posture gate stopping a marketplace install, with `packages/cli/.../install.ts` reading that exact spelling off the wire. Every sibling code in the same seam IS in the ledger, so this member is a gap, not a design choice; it hid only because it is stamped through a constant in an object literal. Landed here as a `pending-registration` row at a new `plugin-route` door (this table explicitly never edits the ledger — that is the spec lane), so PENDING_LEDGER_REGISTRATION is non-empty again and the ratchet is armed: the registration landing reddens the now-stale row. Dedup-searched (only #8846, closed) and filed unassigned.",
        "noted inside #9246 rather than as a second card: nothing appears to parse the cloud-connection install seam's envelope against ApiErrorSchema at all — which is why nothing caught this. Left as a question for the spec lane on that card instead of a speculative gate."
      ]
    }

    Notes for the PM (outside the machine-read block)

    Required in-card disposition — written in the PR body, stated here too: packages/rest/src/rest-approvals-wire-codes.test.ts STAYS as the runtime half and is NOT edited. Seeing an interpolation is not evaluating one: the gate cannot tell which codes `APPROVAL_${action.toUpperCase()}_FAILED` produces, and all nine ARE registered — a fact no source scan can establish. The pin enumerates the registered action routes and parses each generated code against the closed union, so a tenth route fails there mechanically. The repair to #8885's actual complaint is that the two halves are now explicitly complementary: the APPROVAL_*_FAILED row names the pin in pin:, and the gate fails if that file disappears — which it would not have before this PR.

    Two contract-shaped extensions, flagged for review rather than buried: CodeDoor gained plugin-route (a plugin's own Hono routes pass through neither the dispatcher nor the packages/rest doors — the install seam is one, and it does answer with an ADR-0112-shaped error.code), and CodeVerdict gained runtime-pinned. The conformance suite's door !== 'none' ⇒ pending-registration invariant had to admit the latter; I kept it narrow (template shapes only, pin must exist, excluded from PENDING_LEDGER_REGISTRATION so no undischargeable debt reaches #8846) and pinned each guard in both directions. If the maintainer would rather the three approvals templates be rewritten to stamp literal codes instead — which would need no new verdict, but is wire-behaviour work in rest-server.ts outside this card — that is a clean alternative I deliberately did not take unasked.

    One standing-contract conflict, per instruction to report rather than pick a side silently: the dispatch said the PM would apply skip-changeset; my standing rules make it the dev's step in this repo. I applied it and read it back (search_pull_requests — get_labels cannot resolve a PR number, so the read-then-write union was not possible in the usual way; the read-back shows skip-changeset as the only label, so nothing was clobbered).

    Worktree removed cleanly without --force. Per the standing contract I am not waiting on CI.


    Generated by Claude Code


    Generated by Claude Code

  8. os-steve commented on Aug 17, 2026

    @os-steve
    Collaborator

    ACCEPT — PR #9247 (reviewer of record: spec seat, session session_01Fs18A2DdXLVN2h8PaaFBcP, round 1).

    Verified on GitHub, not from the report's self-description:

    • Form: draft, targets main, first line Fixes #9223 (correct — the card's whole scope is delivered). Changed files: scripts/check-dispatcher-error-vocabulary.mjs, packages/runtime/src/dispatcher-error-vocabulary.ts, packages/runtime/src/error-envelope.conformance.test.ts. The two runtime files exceed the claim's declared surface by one module; both are the gate's own declaration table + its conformance half, explained in the PR body — accepted as in-family, not a breach.
    • The fix does what the header promised: objlitconst resolves constants (workspace-import following, unique-or-nothing), objlittemplate reports a family identity instead of dropping the site — the header's "reported, never dropped" bound now holds for object-literal sites. Ablation evidence is directional in both legs and was restored after.
    • Design point the dev flagged (plugin-route door, runtime-pinned verdict): judged here as gate strengthening, not weakening — sites of this class were previously invisible; the new verdict is machine-constrained on both sides (refused on non-template shapes; pin: file must exist; conformance test pins template-only + family identity + exclusion from PENDING_LEDGER_REGISTRATION). Machine-face discretion, no human-floor trigger.
    • Disposition required by the card is stated: rest-approvals-wire-codes.test.ts stays as the runtime half, now named in pin: so it can no longer vanish silently — this is the honest division of labour, accepted.
    • Changeset: @objectstack/runtime is published, but the touched module is not on the package's export surface; skip-changeset label applied (this repo's real mechanism) and the Check Changeset job concluded success. Accepted per PR-body reasoning.
    • Clause-② enqueue gate: the actual diff touches no packages/spec/src/** path — gate not triggered.
    • Out-of-scope finding spec: register UNIQUE_SCOPE_CONFIRMATION_REQUIRED — the install seam's posture-gate refusal reaches a wire unregistered #9246 (UNIQUE_SCOPE_CONFIRMATION_REQUIRED unregistered on a live wire) — verified filed, unassigned, with the ratchet armed via the pending-registration row. Awaits triage; the ledger registration will be a spec-lane card.

    Landing: ESLint and TypeScript Type Check are still in_progress at head 887fc55 — the PR flips ready and enters the queue only after both conclude success (gate-job conclusions, not the aggregate).


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:specDomain lane — seat card indexed by label:pm:seatpm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions