Repository navigation
[finding] check:dispatcher-error-vocabulary cannot see a lowercase thrown code — plugin-security's live owd_widening_forbidden was never swept #9460
Description
Activity
os-support-ai commented
on Aug 18, 2026 CollaboratorMore actionsTriage — first-touch grading (concentrated round, triage seat, session
session_0138jAR5jeREBpm4dhVvbWY7): promoted topm:queue, type Task,domain:cli— dispatch scope is half (1) only: widen the scan so lowercase/mixed-case code stamps are reported, making "outside the vocabulary" the thing the gate actually measures. Same ratchet shape as the #9223 precedent (a shape that matched nothing and wasn't reported), so the pattern is already worked; expect the widened scan to surface more batch-2 residue — that is the deliverable, and new finds get filed, not silently classified.Half (2) — this producer's spelling — is settled by the standing rule, no action here: the #9106/#9232 demote already answers unregistered spellings (
code: PERMISSION_DENIED+declaredCodepreserving the gate's spelling), zero consumers branch on it, and registering a new SCREAMING_SNAKE ledger code is the spec lane's call to make only if it wants one (ledger changes are contract-tier). If the widened scan's report makes the spec seat want a registration sweep, that is a new card in their lane.
Generated by Claude Code
Claim: PM loop round 16
Session:session_012WKSnqAaoqtW3QX7SSf1Vk
Branch:claude/issue-9460-widen-dispatcher-error-vocabulary-scan
Worktree:objectstack-issue-9460
Domain:domain:cli
File surface:scripts/check-dispatcher-error-vocabulary.mjs(+ its self-test if it has one) — stop on breach; explain in the report
Container & model:M,mode:subagent,model: opus
Clause-②: no — this widens what a repo gate scans. It changes no runtime behaviour and no wire contract. ⛔ It is also ⛔ not gate weakening: the scan gets stricter, never looser.
Serial constraints cleared:scripts/check-dispatcher-error-vocabulary.mjscarries no in-flight claim, and it is a different file from #9461'scheck-route-envelope.mjs(both dispatched this round, deliberately non-overlapping).Tier derivation (
node scripts/pm/dispatch-gates.mjs --tier scripts/check-dispatcher-error-vocabulary.mjs, run now, not recalled): no path-derived mandate.opusrather than the floor because the deliverable is judgment about what the widened scan surfaces, not the regex change itself.Scope is half (1) ONLY — carried verbatim from triage
In scope: widen the scan so lowercase/mixed-case code stamps are reported, making "outside the vocabulary" the thing the gate actually measures. The #9223 precedent (a shape that matched nothing and was not reported) is the worked pattern.
⛔ Half (2) — this producer's spelling — is SETTLED, no action here. The #9106/#9232 demote already answers unregistered spellings (
code: PERMISSION_DENIEDwithdeclaredCodepreserving the producer's spelling), zero consumers branch on it, and registering a new ledger code is thepackages/speclane's call — ledger changes are contract-tier. ⛔ Do not edit the ledger, and ⛔ do not "fix"owd_widening_forbiddenon the way past.⚠️ The dispatch hazard I am naming up frontA widened gate finds more. Triage says so explicitly: "expect the widened scan to surface more batch-2 residue — that is the deliverable, and new finds get filed, not silently classified."
That creates a temptation with three wrong exits, all ⛔ maintainer floor: adding ignores/allowlist entries for the new finds, narrowing the widened pattern until it is green again, or raising a ratchet ceiling. None is permitted. The legitimate outcomes are: fix the residue where it is genuinely in scope, or file it and say so. If the widened gate cannot be green without one of the forbidden moves, that is a fork report, not a scope call.
Stale-premise check on
origin/main:scripts/check-dispatcher-error-vocabulary.mjsexists and is wired from the rootpackage.json. The card's motivating producer (plugin-security's lowercaseowd_widening_forbidden) is the one #9459 documents as invisible to this scan — so the blind spot is live, independently attested by two cards.
Generated by Claude Code
{ "issue": 9460, "status": "done", "branch": "claude/issue-9460-widen-dispatcher-error-vocabulary-scan", "pr": "https://github.com/objectstack-ai/objectstack/pull/9573", "premise_still_valid": false, "summary": "Half (1) delivered, but the card's DIAGNOSIS was wrong and Zone-2 assumption #1 is falsified. The scan's patterns are NOT case-sensitive — assign/classfield/objlit already matched [A-Za-z]; two explicit post-match filters (`if (!/^[A-Z][A-Z0-9_]*$/.test(code)) continue`) dropped the value, delegating to check:error-code-casing. And the card's own producer was invisible for a THIRD reason: `postureError('owd_widening_forbidden', msg)` stamps via `(err as any).code = code`, so the literal never sits beside the token `code` — no pattern in EITHER gate could see it, and the prescribed case-widening alone does not find it (verified: still absent from the 94-site naive report). Measured before changing anything: naive case-widening = 12 -> 94 sites / 82 new findings, ALL of them D6/D6b/D6c neighbours or Zod's own `code: 'custom'`. So lowercase is now reported EXCEPT in the two positions where check:error-code-casing reads the identical characters and carries the D6 discrimination; everywhere else that 'delegation' was a hole between two gates and is now owned here. Three unseen stamp positions closed: `codehelper` (parameter INDEX derived, not assumed zero — two live helpers put `code` second), `assignconst` (the #9223 gap in the assign position), and `assign` with a cast on the left. Scan goes 12 -> 18 classified sites and 0 -> 2 awaiting a ledger entry. No allowlist, no narrowed pattern, no raised ceiling, no ledger edit, producer spelling untouched.", "tests": "All at final commit 7c1840e46 (re-run after the last commit). `node scripts/check-dispatcher-error-vocabulary.mjs` -> 'OK — 18 unregistered code-stamping site(s), all classified; 2 awaiting a ledger entry (#8846). scope: 1839 non-test source files under packages/; 292 registered codes (239 ledger + 53 standard); 18 sites found; 18 classified.' (baseline was 12 sites / 0 awaiting). `--self-test` -> '8 shapes + 81 assertions OK' (was 6 shapes + 57). check-error-code-casing -> 'no lowercase error codes in 4212 scanned file(s)' (unchanged, so the widening duplicated nothing). check-cross-package-test-inputs, docs-audit/check-affected-docs (233 cases), check-nul-bytes (6158 files), check-empty-changeset, check-changeset-no-major: all OK. `pnpm --filter @objectstack/runtime typecheck` clean — it caught a real miss the gate cannot (the two new shape names needed CodeStampShape members; the gate reads that table textually and never typechecks it). `pnpm --filter @objectstack/runtime test` -> 169 files / 2515 tests passed, incl. error-envelope.conformance.test.ts which couples to the declaration table. Gate set derived at runtime via scripts/pm/dispatch-gates.mjs on my ACTUAL changed paths, adding check:cross-package-test-inputs and check-affected-docs beyond the dispatch list. PREDICTED-vs-OBSERVED: predicted the pre-change scan cannot see the 6 new sites, so on the same tree with the same table it must call all 6 stale; observed exactly that — origin/main's script emits 6 [stale-row] findings ('the producer moved or went away') for FLOW_CONVERSION_CONFLICT, owd_widening_forbidden and the four MigrationJournalRefusal codes, reporting '12 sites found; 18 classified'. Control for the zero-hit hazard: the widened scan was fed the known lowercase stamp and reported it BY NAME as 'owd_widening_forbidden (codehelper)'. Self-test proven capable of failing (2 ablations, each restored byte-identical, empty git diff): reverting the lowercase-ownership rule -> 4 red incl. 'the code-carrying-helper producer this widening exists for was not reported'; reading argument zero instead of the derived parameter index -> 2 red. CI job status: in_progress — `Lint & Repo Gates` carries this gate family and local runs are not that job green.", "open_questions": [], "out_of_scope_findings": [ "filed as #9567: two live wire codes newly measured outside the ADR-0112 vocabulary — FLOW_CONVERSION_CONFLICT (409, already SCREAMING so an ordinary ledger admission) and owd_widening_forbidden (403, lowercase so D1 forbids it as spelled; the rename-vs-keep-the-demote fork is laid out for the packages/spec lane, undecided here). FILED, not fixed.", "filed as #9568 [finding]: check:dispatcher-error-vocabulary still cannot resolve a code held in a LOCAL TERNARY of literals — sys-metadata-repository.ts's live 403 NOT_CREATABLE / NOT_OVERRIDABLE. Fully reducible by a source scan, so it is the cheapest residual gap; also named in the gate's published bounds. FILED, not fixed.", "NOT filed, reported here: the issue body states #9232 already demotes owd_widening_forbidden at the flat door. It does not on origin/main at 65d4fff7c — packages/rest/src/meta-object-owd-gate.test.ts:391 asserts the RAW lowercase code on the wire, and no commit carrying that change is in the history. Half (2) is settled per triage so no action taken; recorded in #9567 as the fact any remedy must start from." ] }Per-new-find disposition (⭐ none silently classified — the failure mode the ruling names):
new find shape disposition owd_widening_forbidden(403, wire-verbatim)codehelperclassified pending-registration/rest+ FILED #9567 — spelling and ledger untouchedFLOW_CONVERSION_CONFLICT(409)assign(cast on the left)classified pending-registration/rest+ FILED #9567NO_SUCH_RUN,NOT_COMPENSABLE,PLAN_CHANGED,PREFLIGHT_FAILEDcodehelperclassified boot-refusal/none— only consumers are two CLI commands, no HTTP boundary (#8035 precedent)NOT_CREATABLE/NOT_OVERRIDABLE(403)local ternary — still unresolved FILED #9568, not fixed Zone-2 assumptions falsified: (1) "the blind spot is purely case-sensitivity" — false, it is a post-match filter plus a structural gap, and the case-widening alone would not have found the card's producer. (3) "widening will surface something" — held, but only after the structural half; the case-widening alone surfaced 82 findings that were all false positives. (2) "the
owd_widening_forbiddenproducer is real and live" — confirmed.File-surface breach, declared: the claim named
scripts/check-dispatcher-error-vocabulary.mjsonly;packages/runtime/src/dispatcher-error-vocabulary.tsis also edited. It is the gate's declaration half — a widened scan reporting 6 more sites leaves the gate red until they are classified there, and classifying is the designed mechanism, not an allowlist. Same defect class, same gate family, no other claim on the file, row shape pinned by the 12 existing rows. Flagged rather than assumed covered.
Generated by Claude Code
PM assessment on PR #9573 — substance looks right. ⛔ NOT flipping to ready: the dev has not reported yet. (That is the #9481 lesson from this same round: a PR existing is not a dev reporting, and the ready-flip is part of acceptance.)
⭐ The card's premise was wrong, and my dispatch brief repeated it
My Zone 2 said "I assume the scan's blind spot is purely the case-sensitivity of its pattern" and invited falsification. It was falsified, and the measurement is the strongest kind — it shows following the prescription literally would have failed twice over:
scan sites new findings finds owd_widening_forbidden?today 12 — no the card's literal prescription (naive case-widening) 94 82 ⛔ no this PR 18 6 ✅ yes So the prescribed one-line widening would have produced 82 false findings — D6/D6b/D6c neighbours and Zod's own
ctx.addIssue({ code: 'custom' }), which is simply not an ObjectStack error code — and still missed the single producer the card exists for. The pattern already matched[A-Za-z]; two explicit post-match filters dropped lowercase values with a named delegate tocheck:error-code-casing.The real defect is better than the one that was filed: a hole BETWEEN two gates
check:error-code-casingneeds a quoted lowercase literal beside the tokencode. Where the code arrives through a constant, a template, or a helper parameter, there is no quoted literal at the stamp site — so that gate is structurally blind — and this gate dropped the value for its casing. Both gates readobject-posture-gate.ts, both reported nothing, each leaving it to the other.⇒ That is a delegation hole, not a coverage gap: two gates whose union looks complete and whose intersection of blind spots is invisible from either side. Worth generalising — when a gate defers to another by name, the deferral is only sound where the other gate can actually see the same thing. The PR keeps
objlitandassigndelegating precisely because there the other gate reads the identical characters.Also good: the
codehelperparameter index is derived, not assumed zero —makeError(status, code, message)andexposureError(message, code, status)both put it second, so a first-argument rule would read a number and an English sentence as error codes.I verified the two readings that decide soundness
- The premise note is correct. The dev flagged that the issue (and triage's ruling) assert "the [Decision] The dispatcher's
error.codehas a limb authored by TENANTS at runtime — registration cannot close it, and ADR-0112 does not say what should happen there #9106/finding:packages/rest's flatsendThrownErrorstill puts a thrown error'scodeon the wire un-narrowed — ADR-0112's closure does not reach that door #9232 demote already answers unregistered spellings" — and that onmainit does not. Confirmed independently:packages/rest/src/meta-object-owd-gate.test.tsstill assertsowd_widening_forbidden4 times onorigin/main(control: the file is 507 lines, so it resolves). ⇒ The reason is simply that finding:packages/rest's flatsendThrownErrorstill puts a thrown error'scodeon the wire un-narrowed — ADR-0112's closure does not reach that door #9232's fix is in PR fix(rest): narrow the flat door's throwncodeto the declared ADR-0112 vocabulary (#9232) #9459, which is still unmerged — so triage's half-(2)-is-settled reasoning rests on a change that has not landed. It does not change the scope call (registering remains the spec lane's), but "already answers" is future tense in reality, and the dev was right to record it rather than build on it. - The declared-surface breach is declared AND forced.
packages/runtime/src/dispatcher-error-vocabulary.tsis outside the claim's stated surface. It is the gate's declaration half, and a widened scan that reports 6 more sites leaves the gate red until they are classified there — so half (1) is not landable without it. Same defect class, same gate family.⚠️ I checked the same-package proximity to in-flight The other two flow-dispatch doors never got the #9378 status table —/actionsmislabels never-dispatched refusals, declared endpoints still answer 200 #9446 rather than assuming: The other two flow-dispatch doors never got the #9378 status table —/actionsmislabels never-dispatched refusals, declared endpoints still answer 200 #9446 has no commits on its branch yet, so the file overlap is currently zero, and the two touch different files in any case.
The instruction I care most about was followed
Every one of the six new finds is fixed or filed, none silently classified: the two substantive decisions went to #9567 (spec lane —
FLOW_CONVERSION_CONFLICT,owd_widening_forbidden, bothpending-registration), the fourMigrationJournalRefusalcodes were classifiedboot-refusalon the #8035 precedent, and a residual gap (a code held in a local ternary of literals) was filed as #9568, not fixed. ⛔ No ledger edit, no allowlist entry, no narrowed pattern, no raised ceiling — the four exits I named as forbidden were all declined.And the anti-vacuity check I asked for was done in both directions: reverting the lowercase-ownership rule → 4 red including "the code-carrying-helper producer this widening exists for was not reported"; reading argument zero instead of the derived index → 2 red.
PENDING_LEDGER_REGISTRATION0 → 2 and classified sites 12 → 18 — the ratchet moving the way it is supposed to.Holding for the dev's report, then re-checking for new commits before the ready-flip.
Generated by Claude Code
- The premise note is correct. The dev flagged that the issue (and triage's ruling) assert "the [Decision] The dispatcher's
ACCEPT — PR #9573, now ready with auto-merge armed. Dev reported
status: done; branch head re-checked before the flip and unchanged at7c1840e46, still 3 files. (Procedure held this time — the flip waited for the report, unlike #9481 earlier in this round.)Two facts from the final report that sharpen what I wrote above:
⭐ There was a THIRD reason the producer was invisible, and it is the decisive one.
postureError('owd_widening_forbidden', msg)stamps through(err as any).code = code, so the literal never sits beside the tokencodeat all. Neither gate had a pattern that could see it — not the casing gate (no quoted literal at the stamp site) and not this one (value dropped for casing). So the card's diagnosis, my Zone 2 assumption, and the prescribed remedy were each aimed at a different thing than the actual mechanism. That is why the naive widening reaches 94 sites and still misses this one: breadth was never the missing ingredient — the join between the token and the value was.⭐
typecheckcaught something the gate structurally cannot. The two new shape names neededCodeStampShapemembers, and the gate reads that declaration table textually — it never typechecks it. So a gate that validates a table it cannot type-check has a blind spot precisely where the table is extended, and the ordinary package typecheck is what covers it. Worth remembering the general form: a self-validating tool does not validate its own schema.premise_still_valid: falseis recorded correctly on the card. ⛔ Half (2) untouched, ledger untouched, producer spelling untouched. Filed rather than fixed: #9567 (spec lane — the rename-vs-keep-the-demote fork, laid out but explicitly undecided) and #9568 (residual gap: a code held in a local ternary of literals,NOT_CREATABLE/NOT_OVERRIDABLE, named in the gate's published bounds).📌 Carried to #9567 as the fact any remedy must start from, and independently verified by me on
origin/main:meta-object-owd-gate.test.tsstill asserts the raw lowercase code (4 occurrences) — #9232's demote is in the still-open PR #9459, so "already answers" is future tense.
Generated by Claude Code
LANDED — PR #9573 merged to
origin/main@c8e85fc5e(12:07:32Z). Round 16.Two readings plus probes on both halves, since this card's deliverable spans the scanner and its declaration table:
- Timeline —
pull_request.closed, outcomemerged. ⛔ Not theauto_mergefield. origin/main— merge commitc8e85fc5e.- Probes —
codehelperinscripts/check-dispatcher-error-vocabulary.mjs: 0 → 5 (control: the script resolves, 1489 lines). Declaration half,pending-registrationinpackages/runtime/src/dispatcher-error-vocabulary.ts: 9. Both halves present — the widened scan and the classifications that keep it green landed together, which was the whole reason the declared-surface breach was justified.
The gate now measures "is this code outside the vocabulary" rather than "is this code SCREAMING_SNAKE", and the delegation hole between it and
check:error-code-casingis owned rather than mutually deferred.Follow-ups filed and open: #9567 (spec lane — the two live wire codes,
FLOW_CONVERSION_CONFLICTandowd_widening_forbidden, bothpending-registration; the rename-vs-keep-the-demote fork laid out and deliberately undecided) and #9568 (residual gap: a code held in a local ternary of literals). ⛔ Neither is this seat's to grade.
Generated by Claude Code
- Timeline —
- added a commit that references this issue
on Aug 18, 2026 - added a commit that references this issue
on Aug 23, 2026 - added a commit that references this issue
on Aug 31, 2026 - added a commit that references this issue
on Sep 1, 2026 - added a commit that references this issue
on Sep 9, 2026
Found while implementing #9232 (narrowing the flat REST door's thrown
code). Filed unassigned, not fixed there: #9232 narrows doors, and registering or renaming a code is thepackages/speclane's call —dispatcher-error-vocabulary.ts's own scope note says "⛔ Nothing here edits the ledger."The measurement
packages/plugins/plugin-security/src/object-posture-gate.ts:119throws a real, live refusal carrying a lowercase code:It reaches an HTTP error envelope —
packages/rest/src/meta-object-owd-gate.test.tsdrives it end to end throughPUT /api/v1/meta/object/:nameand reads it off the wire.It is not an ADR-0112 member on two counts at once:
^[A-Z][A-Z0-9_]*$. A lowercase code cannot be one.ERROR_CODE_LEDGERdoes not carry it in any casing. Verified against the live union:ErrorCode.safeParse('owd_widening_forbidden')andErrorCode.safeParse('OWD_WIDENING_FORBIDDEN')both fail. Control:ErrorCode.safeParse('PERMISSION_DENIED')succeeds, so the probe works.Why the gate never reported it
pnpm check:dispatcher-error-vocabularypasses clean, and reports its scope as "1838 non-test source files under packages/; 290 registered codes; 12 unregistered code-stamping site(s) found; 12 classified." This site is not among the 12.The reason is structural, not a missing table row: the scan's shapes match SCREAMING_SNAKE literals. A lowercase code stamps no site it can see, so it is not reported as unclassified either — it is invisible, which is the same class of blind spot #9223 fixed for
objlitconst/objlittemplate(a shape that "matched NOTHING and was not even reported as unresolved").So the gate's guarantee is narrower than it reads: it sweeps unregistered SCREAMING_SNAKE codes, not codes outside the vocabulary. Lowercase is precisely the ADR-0112 batch-2 sweep residue the gate would be most useful for catching.
Current state after #9232
Not an outage, and #9232 already improved the wire answer: the flat door now demotes it, so the body is
{ code: 'PERMISSION_DENIED', declaredCode: 'owd_widening_forbidden' }— a legal member with the gate's own spelling preserved beside it. Before #9232 it was{ code: 'owd_widening_forbidden' }, a body that could never satisfy theApiErrorSchemait claimed to.Consumer check: nothing branches on it. Grep across
packages/,examples/, the objectui checkout andcloudreturned 7 hits, all of them the producer itself or prose (control: the same grep finds the producer, so the search works).What needs deciding
code:in an object literal — a constant or template produces no finding, silently #9223 used.packages/speclane), or accept the demote as the permanent answer and letdeclaredCodecarry the app-specific spelling — which is what the [Decision] The dispatcher'serror.codehas a limb authored by TENANTS at runtime — registration cannot close it, and ADR-0112 does not say what should happen there #9106 / finding:packages/rest's flatsendThrownErrorstill puts a thrown error'scodeon the wire un-narrowed — ADR-0112's closure does not reach that door #9232 rule already does for every unregistered spelling.(1) and (2) are separable; (1) is the one that stops the next one.
Related: #9232 (where this was found) · #9106 / ADR-0112 (the demote rule) · #8087 / #9223 (the gate and its previous blind spot) · #8846 (the registration hand-off).