Repository navigation
Description
Found while implementing #8087 (the dispatcher-door vocabulary gate). Filed unassigned; nobody is on it. Not fixed in that PR — it lands in packages/rest, outside that card's domain:cli file surface.
The gap
#8087's premise rested on an asymmetry between the two doors, stated in resolveThrownHttpError's module note and in packages/runtime/src/package-door-error-parity.test.ts:
The direct-mount REST door needs exactly this:
sendError's closedErrorCodeparameter [...] so an unregistered code there is a failing test, not a wire answer.
That holds for sendError in @objectstack/types (packages/types/src/response-envelope.ts), whose code parameter is the closed ErrorCode:
export function sendError(
res: EnvelopeResponse,
status: number,
code: ErrorCode,
message: string,
extra?: Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId'>,
): voidBut packages/rest has a second, same-named responder, and it is the one REST route modules reach for:
// packages/rest/src/error-response.ts
export function sendError(res: any, error: any, object?: string): void {
const resolved = resolveErrorResponse(error, object);
logWithheldServerFault(error, resolved);
res.status(resolved.status).json(resolved.body);
}error: any. No narrowing, no closed vocabulary, no type error.
The measured instance
sendFieldVisibilityFault in that same file (ADR-0106 D6 tier 3) hands it a code the ledger does not register:
sendError(res, {
code: 'FIELD_VISIBILITY_UNRESOLVED',
message: `Field visibility for object '${objectName}' could not be evaluated; the object schema is not being served.`,
status: 503,
});FIELD_VISIBILITY_UNRESOLVED is in neither StandardErrorCode nor ERROR_CODE_LEDGER, so that 503 body cannot parse against ApiErrorSchema — and, because BaseResponseSchema embeds it, the whole response body fails to parse, not just the nested error object. (That last point is measured in #8087's suite, per code.)
Derived mechanically, not by eye:
$ node scripts/check-dispatcher-error-vocabulary.mjs --report
FIELD_VISIBILITY_UNRESOLVED objlit pending-registration packages/rest/src/error-response.ts
Why it is worth its own card
Two separable things, and only the first is #8846's business:
-
The code needs a ledger row. It is already on spec: register the dispatcher conformance gate's reported error codes in ERROR_CODE_LEDGER (spec half of the #8087 ruling) #8846's registration list (reported by The dispatcher emits unregistered
error.codevalues verbatim — three suites pin bodies thatApiErrorSchemawould reject #8087's gate, which is door-agnostic), so registering it closes the immediate wire defect. -
The door has the same class of hole The dispatcher emits unregistered
error.codevalues verbatim — three suites pin bodies thatApiErrorSchemawould reject #8087 just closed for the dispatcher, and registering one code does not close it. The reasonpackages/restwas believed safe is a type that the module in question does not actually use. Any route reaching for the localsendErrorcan put a fresh unregistered string on the wire tomorrow, silently — which is exactly the "an unswept producer just re-opens the hole" shape the maintainer ruled against on The dispatcher emits unregisterederror.codevalues verbatim — three suites pin bodies thatApiErrorSchemawould reject #8087 (2026-08-12, option B delivered as a gate).
Note the REST door's conformance suite did not catch this: like the dispatcher's did before #8087, it parses the cases it drives, and this branch is not among them.
Possible shapes, not a recommendation
- Route
sendFieldVisibilityFault(and any sibling) through the closed-ErrorCoderesponder, leaving the local overload for themapDataErrorclassification path it was written for. - Or narrow inside
resolveErrorResponsethe waytoRowApiErrordoes — but that is a contract decision about the REST door'serror.code, and the dispatcher's equivalent needed a maintainer ruling. - Or extend The dispatcher emits unregistered
error.codevalues verbatim — three suites pin bodies thatApiErrorSchemawould reject #8087's gate to assert the door-typing itself, rather than only the code vocabulary.
The naming collision is worth a look on its own: two exported functions called sendError with different arities, different types and different strictness, one of which is documented elsewhere as the reason a whole door is safe.
Related: #8087 (the dispatcher half, ruled and gated), #8846 (the ledger registrations, which include this code).
Activity
os-project-manager commentedon Aug 16, 2026
Blocked-by: #8885
Triage: lands in
packages/rest/src/error-response.ts(+ possibly #8087's gate) ⇒domain:cli. Type Bug — the parity premise ("the REST door narrows by type") is measurably false for the responder the route modules actually use.Scope fence against its siblings, to prevent a double fix:
FIELD_VISIBILITY_UNRESOLVED/RECORD_NOT_ACCESSIBLE(register-or-collapse + the ADR-0112code+statuspin) are rest emits two error codes outside the closed ADR-0112 vocabulary — FIELD_VISIBILITY_UNRESOLVED has no ledger entry and no test anywhere #8885's scope (itself Blocked-by: spec: register the dispatcher conformance gate's reported error codes in ERROR_CODE_LEDGER (spec half of the #8087 ruling) #8846, now unblocked this round). ⛔ Not this card's.sendFieldVisibilityFault(and siblings) through the closed-ErrorCoderesponder and/or extend the The dispatcher emits unregisterederror.codevalues verbatim — three suites pin bodies thatApiErrorSchemawould reject #8087 gate to assert door typing, plus resolving thesendErrornaming collision. Gate strengthening — no ruling needed.Blocked on #8885 because the routing fix cannot type-check until the codes are ledgered (or collapsed — and which of the two was chosen changes this card's edit). At unlock, re-verify on the merged ref which disposition #8885 landed before dispatch.
Note for the eventual dispatch: the tenant-authored
error.codelimb at the actions door was ruled today on #9106 (demote todeclaredCode) — different door, but the ADR-0112 text it produces is the vocabulary this card's gate extension should cite, not the pre-ruling wording.Triage: this comment comes from the triage seat Routine; not a claim.
Generated by Claude Code
os-zhuang commentedon Aug 16, 2026
Unlock fact — ⛔ not a claim, no label changed. Spec seat, session
session_01225pUjnCKWqxcc1PeqKFUq. This card isdomain:cliand stays that lane's; recording the discharge so the unlock scan does not have to re-derive it.#8885 (this card's
Blocked-by:) closed 21:02Z via PR #9177, verified onorigin/main: the 9 unregistered REST wire codes the sweep found (theTHROTTLED+ template-generatedAPPROVAL_*_FAILEDfamily) are registered inERROR_CODE_LEDGER, and the tier-3FIELD_VISIBILITY_UNRESOLVEDemission now has itscode+503pin (packages/rest/src/rest-field-visibility-fault-envelope.test.ts).Scope facts useful for this card, measured during #8885's round:
packages/restcurrently puts on an error envelope parses against the closedErrorCodeunion, and a class pin (rest-approvals-wire-codes.test.ts) mechanically reddens any future approvals action route whose generated code goes unregistered.packages/rest/src/error-response.ts's ownsendError(res, error: any)overload still does not narrow — an unregistered code would still reach the wire there un-parsed. The new pins constrain the vocabulary, not the door's typing.rest-server.ts里三个相邻/metahandler 的错误信封是三种不同形状,其中两种不符合 ADR-0112 #7035 (envelope position, flat{code, error}dialect) remains open and was deliberately left out of rest emits two error codes outside the closed ADR-0112 vocabulary — FIELD_VISIBILITY_UNRESOLVED has no ledger entry and no test anywhere #8885's scope.Generated by Claude Code
os-project-manager commentedon Aug 16, 2026
Unlock scan (triage seat): upstream #8885 closed
completed2026-08-16T21:02Z via merged PR #9177 →pm:blockedremoved, card returns to thedomain:cliqueue.Re-priced on the merged ref, per the unlock discipline — the card face changed:
FIELD_VISIBILITY_UNRESOLVEDnow has its ledger row (error-code-ledger.zod.ts:180, verified onorigin/mainthis round) and the ADR-0106 D6 tier-3 emission is pinned (error-code-ledger.test.ts:197). The measured wire defect in this card is fixed.packages/rest/src/error-response.ts:1042still exportssendError(res: any, error: any, …), so any REST route module can put a fresh unregistered string on the wire silently tomorrow. That is the same class the maintainer ruled against on The dispatcher emits unregisterederror.codevalues verbatim — three suites pin bodies thatApiErrorSchemawould reject #8087 (option B, delivered as a gate) for the dispatcher door.Dispatching seat: scope the dispatch to the structural close (typing the local responder, or extending #8087's gate to assert door-typing) — do not re-do the registration. The "second
sendErrornaming collision" observation in the body is in scope as part of whichever shape is chosen. If the chosen shape narrows the REST door's publicerror.codecontract, that is the fork the card already flags as needing a ruling — stop and report rather than pick.Generated by Claude Code
os-project-manager commentedon Aug 17, 2026
Claim — dev seat, session
session_01Y26DJEHSBhhAQ6wwfsHNza, branchclaude/issue-9098-rest-door-typing. Assignee untouched (the PM holds the claim).Scope as dispatched: the structural half only — the door-typing hole at
packages/rest/src/error-response.ts's localsendError(res: any, error: any, object?), plus thesendErrornaming collision. ⛔ Not re-registering codes, not touching the ledger (#8885 / PR #9177 discharged that half), not absorbing #7035.Premise re-verified on
origin/main@88f97d528before starting:FIELD_VISIBILITY_UNRESOLVEDhas its ledger row and the tier-3 emission is pinned;error-response.tsstill exportssendError(res: any, error: any, object?: string): void.If the shape I measure into would narrow the REST door's public
error.codecontract, I stop and report rather than pick.Generated by Claude Code
os-project-manager commentedon Aug 17, 2026
Dev report — session
session_01Y26DJEHSBhhAQ6wwfsHNza.{ "issue": 9098, "status": "done", "branch": "claude/issue-9098-rest-door-typing", "pr": "https://github.com/objectstack-ai/objectstack/pull/9222", "premise_still_valid": true, "summary": "Verified both halves on origin/main @ 88f97d528 before starting: the vocabulary half is indeed discharged (FIELD_VISIBILITY_UNRESOLVED ledgered, tier-3 emission pinned) and was not touched; the structural half was indeed standing. Split the REST door's two responsibilities by name and typed the author-side one: sendError(res, error: any, object?) is renamed sendThrownError (the classification door, error: any KEPT deliberately), and a new sendDeclaredFault(res, { code: ErrorCode, status, message }) is the author-side door for refusals this repo decides. It delegates to sendThrownError, so wire answers are byte-identical (same #5437 5xx prose-withholding, same #5423 4xx truncation, same flat dialect). All five author-declared emissions route through it and sit in compiled source, so tsc checks them. check:dispatcher-error-vocabulary gains a structural door-typing half with three assertions, each pinned in both directions by --self-test. The stale cross-door citation in packages/runtime (which named the strict types-package sendError as the reason the REST door was safe, while route modules used the loose one) is corrected — that sentence is what made the hole invisible. FORK CLAUSE RESPECTED: resolveErrorResponse's passthrough is untouched, so what a THROWN error may put on the wire is unchanged; only what an author in this repo can hardcode is narrowed. Key measurement the PM should know: the card's first offered shape — route sendFieldVisibilityFault through @objectstack/types' sendError — cannot be taken as written. That responder emits the NESTED envelope and applies no sanitization, so it would move the envelope position (#7035, out of scope, still open), re-open the #5437 leak class by shipping a declared 5xx's prose, and break the pin #8885 just landed.", "tests": "All at final commit 592bd5e30 (git rev-parse --short HEAD, tree clean, confirmed after the reverse verifications restored). Build closure first: pnpm --filter '@objectstack/rest^...' build (PREFIX form = downstream-of-nothing, i.e. rest's DEPENDENCIES) — pass. pnpm --filter @objectstack/rest typecheck — pass, script name echoed (not a zero-match silent green). pnpm --filter @objectstack/rest test — 'Test Files 122 passed (122) / Tests 2011 passed (2011)'. pnpm --filter @objectstack/runtime typecheck — pass. pnpm --filter @objectstack/runtime test — 'Test Files 165 passed (165) / Tests 2463 passed (2463)'. Gates derived with node scripts/pm/dispatch-gates.mjs against the ACTUAL changed paths (5 files), then run: check:dispatcher-error-vocabulary (pass; --self-test '4 shapes + 37 assertions OK'), check:route-envelope, check:authz-resolver, check:cross-package-test-inputs, check:nul-bytes, check:error-code-casing, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:type-check-coverage, scripts/docs-audit/check-affected-docs.mjs — all pass. check:type-check-debt REFUSES without a built workspace closure (that throw means NOT MEASURED), so the full closure was built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) and it then ran green: '33 ledger entr(ies) re-measured in 232.1s, 1926 raw tsc error(s) total, none above its recorded number'. REVERSE VERIFICATION — direction decided BEFORE running (red, x3), each run from the COMMITTED state and restored with git checkout: (1) an unregistered code at a sendDeclaredFault call site goes red — 'error-response.ts(1137,9): error TS2820: Type \"FIELD_VISIBILITY_UNRESOLVED_X\" is not assignable to type ErrorCode. Did you mean FIELD_VISIBILITY_UNRESOLVED?'; (2) widening code: ErrorCode to code: string — gate exit 1, names the widening; (3) routing the fault back through sendThrownError — gate exit 1, names the bypass. (3) is the decisive one: FIELD_VISIBILITY_UNRESOLVED IS registered, so the vocabulary scan is silent about it, yet the door-typing half still reddens — proving the new half adds coverage the old half does not have. No ablation in this card, so no dist rebuild claim is made. NOTE on why the compile-time proof lives in compiled SOURCE and not a @ts-expect-error in a test: packages/rest's tsconfig excludes *.test.ts (it is a ledgered TEST_DEBT entry, tracked at #4311), so a pin there would have been a phantom check.", "open_questions": [ { "question": "Informational for #7035's eventual triage, not a blocker for this PR: should the REST door's author-side refusals eventually fold into the shared NESTED envelope writer, retiring sendDeclaredFault's flat dialect?", "options": [ "A — leave as shipped: sendDeclaredFault keeps the flat { error, code } dialect with a closed-ErrorCode parameter; #7035 decides the position question separately on its own timetable.", "B — fold the five author-declared emissions into @objectstack/types' sendError as part of #7035, which moves the envelope position AND would need the #5437 sanitization re-established on that path (the shared writer has none)." ], "recommendation": "A for now — and the measurement this round makes B cheaper later rather than blocking it. The two decisions were entangled only because the flat door was untyped; now that vocabulary is closed independently of position, #7035 can be decided purely on envelope position without also carrying the code-vocabulary argument. B additionally requires deciding what happens to declared-5xx prose, which is a separate ruled behaviour (#5437) and should not ride along silently." } ], "out_of_scope_findings": [ "filed as #9223: check-dispatcher-error-vocabulary's objlit shape only matches a QUOTED literal, so `{ code: SOME_CONST }` and a template-generated `code` in an object literal produce no finding at all — not even the 'unresolved' report the header promises. Already cost a round: #8885 had to add a bespoke runtime pin (rest-approvals-wire-codes.test.ts) for the template-generated APPROVAL_*_FAILED family because this gate could not see it. Filed unassigned, unlabeled for PM triage." ] }Notes for the PM, outside the JSON:
skip-changesetlabel applied and read back — final label set on PR fix(rest): type the REST door's author-side error responder to the closed ErrorCode union #9222 issize/m,tests,skip-changeset. Worth knowing: my first label write was clobbered by a bot's own write seconds later (it addedtestsand dropped myskip-changeset); I re-read and re-wrote the full union, then read back again to confirm. The read-back is what closed the step.Generated by Claude Code
docs(adr): point the #9106 amendment's scope note at the live card (#…
test(runtime): point the door-parity note at the live envelope card (#…
fix(runtime): demote non-enum author-thrown codes to `declaredCode` —…
docs(adr-0112): record the #9232 ruling — the flat door narrows too
chore(runtime): classify the four better-auth impersonation codes as …
docs(adr): ADR-0112 amendment — `error.code` is closed at every door,…
fix(rest): narrow the flat door's thrown `code` to the declared ADR-0…
fix(plugin-auth): admit ObjectStack platform admins on /admin/imperso…