Skip to content

fix(rest): the package door carries the demoted producer code on declaredCode (#12405) - #12508

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-12405-package-routes-declaredcode
Aug 26, 2026
Merged

fix(rest): the package door carries the demoted producer code on declaredCode (#12405)#12508
os-litant merged 2 commits into
mainfrom
claude/issue-12405-package-routes-declaredcode

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#12405

sendThrownError in packages/rest/src/package-routes.ts resolved every throw through the shared ADR-0112 rule and then forwarded { details } and nothing else, so the demoted producer spelling it was holding in the same local was dropped one line later. It now reads demotedDeclaredCode(thrown) and spreads it into the shared envelope writer's extra.

Premise re-verified on current origin/main

Triage verified this at 4045b95; re-measured here at e0bdbc30b (branch point) and again on the merged tree at 904c0afe4:

  • sendThrownError still forwarded only { details } — unrepaired, nothing fixed it in passing.

  • sendError's extra admits declaredCode (packages/types/src/response-envelope.ts, types: sendError's extra cannot carry declaredCode, so every nested-envelope route silently drops an unregistered producer's own code spelling #11719 / db8c288), so the repair compiles.

  • Direct resolver measurement, the shape the fix depends on:

    thrownstatuscodedemotedDeclaredCode
    { status: 409, code: 'PACKAGE_IS_HAUNTED' }409RESOURCE_CONFLICTPACKAGE_IS_HAUNTED
    { status: 409, code: 'DESTRUCTIVE_CHANGE' }409DESTRUCTIVE_CHANGEundefined
    { code: 'SQLITE_ERROR' } (no status)500INTERNAL_ERRORSQLITE_ERROR
    { status: 500, code: 1234 }500INTERNAL_ERRORundefined
    new Error('kaboom')500INTERNAL_ERRORundefined

Adopting an existing rule, not inventing one

demotedDeclaredCode is read, never thrown.declaredCode raw: presence MEANS demotion, and the resolver sets the raw field for a REGISTERED spelling too. The two sibling doors already do exactly this — the flat /data door's thrownCodeFields (error-response.ts, #9232), and, more directly, the runtime dispatcher's errorFromThrown (packages/runtime/src/http-dispatcher.ts, #9106), which is the TWIN transport for /api/v1/packages.

That twin is why this reads as a disagreement rather than an omission: the dispatcher domain has emitted this channel all along, while this direct-mount registrar — which registers first and is therefore the transport production serves for the three routes both declare — dropped it. One path, two doors, disagreeing on a declared channel.

Reachability — the card's own unmeasured half

Two different claims, measured separately.

The channel is live at this door. Every producer reaching sendThrownError is injected: the PackageService is resolved from the service registry per request (#7563), the protocol slice is duck-typed into PackageRoutesOptions, and resolveExecutionContext is handed in by direct-mount-composition.ts. The door forwards whatever they throw, so the demote fires on any string code outside @objectstack/spec's ledger. All four seams are driven in the new suite.

The framework's own producers do not populate it — by gate, not by accident. Scanned every .code = '…' and Object.assign(new Error(…), { code }) spelling in non-test source under packages/ (107 sites, 47 distinct spellings) against the real ApiErrorSchema: exactly one is unregistered, and it is a docblock example (dispatcher-error-vocabulary.ts:64). That is pnpm check:dispatcher-error-vocabulary doing its job — it fails on an unswept platform producer precisely so a platform code cannot silently demote off the wire.

Tracing the four seams confirms it end to end: PackageService.publish/delete re-throw only via declaresHttpAnswer (the STATUS channel, never .code), get/list re-throw only the branded seam refusal (SERVICE_UNAVAILABLE), and protocol.deletePackage escapes only with TENANT_SCOPE_REQUIRED or metadataStoreUnavailableError — every one a ledger member. Its per-item deletes and its plugin cleanups are each caught and reported as failed[]/cleanups[].

So the live population is the limb no ledger enumerates, which is what declaredCode was declared for: a metadata app's own thrown .code across the QuickJS boundary (#7867), and a downstream repo's codes — kept out of this ledger BY the federation ruling (2026-08-03, re-confirmed 2026-08-09) while resolveThrownHttpError still judges membership against the framework ledger alone. Severity, not validity: an unregistered platform-authored spelling would be demoted and dropped identically.

The instrument can say no

Proved in three places, on terms that are not substrings of the ones under test:

  • the vocabulary instrument answers true for RESOURCE_NOT_FOUND/DESTRUCTIVE_CHANGE and false for ZQX_NEVER_REGISTERED/PACKAGE_IS_HAUNTED;
  • the census grep carries a positive control (sendError, non-zero in all seven modules) and a negative one (quixoticNonexistentSymbol, zero in all seven);
  • section 5 of the new suite is the no on a real path: a real ObjectQL, a real ObjectStackProtocolImplementation and a failing driver, driven through DELETE /api/v1/packages/:id, answer a REGISTERED 503 SERVICE_UNAVAILABLE and must carry no declaredCode.

The #11719 census, re-run rather than inherited

It holds. Across the seven modules on the shared sendOk/sendError pair, only package-routes.ts consults the shared resolver (4 hits); storage-routes, settings-routes, admin-routes, external-datasource-routes, i18n-service-plugin and share-link-routes return 0 for resolveThrownHttpError / demotedDeclaredCode / classifiedRefusalAnswer. One addition worth recording: rest-server.ts is an eighth importer of the shared writer (as sendEnvelopeError) and does hold a resolved answer — but it is the fenced file (PR #12421 / #11926) and is untouched here.

The 5xx withhold is scoped to the prose, deliberately

The demote is NOT suppressed on the sanitised 5xx arm. This door's withhold (#8086) replaces a leaky message and its own docblock scopes it: "Only the PROSE is withheld: status, code and details are untouched." declaredCode is a code channel, and the twin applies no status condition to it either — a condition added here would be a new rule at one door and would re-create the divergence this closes. Pinned as a live case so a reader sees it was chosen. There is a reading under which a driver-authored spelling on a sanitised 5xx is itself disclosure; that fork is reported to the PM rather than decided here.

Reverse verification, and the half the prediction got wrong

Predicted before running: reverting sendThrownError to its pre-repair forward turns sections 1, 3 and 4 RED and leaves sections 2 and 5 GREEN.

Measured: 30 failed / 125 passed of 155. Sections 1, 3 and 4 went red as predicted — and so did the convergence block of section 5, which the prediction had lumped in with its real-producer sibling and should not have: that block compares the wire against demotedDeclaredCode over six shapes, four of which demote, so red is correct there. What stayed green is what actually asserts ABSENCE. package-routes-coded-error-mapping.test.ts contributed 4 of the 30 (its new line at the four seams). The corrected account is recorded in the suite header rather than smoothed over.

The mutation was proved on disk before the run — blob 81c113c… to 1ac288d…, injected marker count 0, removed marker count 1 — and the restore proved after it: git diff HEAD clean and the blob back to the HEAD hash. The script carried a trap … EXIT INT TERM with absolute paths.

Verification

Everything below ran at 5f1d5235c, the final commit, and each verdict is quoted from the gate's own line.

  • pnpm --filter @objectstack/rest test148 files, 2403 tests passed
  • pnpm --filter @objectstack/rest typechecktsc --noEmit, exit 0
  • pnpm lint (whole repo, eslint . --no-inline-config) — exit 0, no narrowing claimed
  • pnpm check:route-envelope — OK (this file's gate; the two #9559 ratchet lines are pre-existing and unmoved)
  • pnpm check:dispatcher-error-vocabulary — "OK — 21 unregistered code-stamping site(s), all classified"
  • pnpm check:type-check-debt — "OK — 32 ledger entr(ies) re-measured … none above its recorded number"
  • pnpm check:engine-double-contract — "OK — 416 pinned, 133 in the DEBT ledger, 2 exempt"
  • pnpm check:where-matcher — "303 matcher(s) … none new"
  • pnpm check:query-options-erasure — "ratchet holds … none new"
  • pnpm check:cross-package-test-inputs — "OK: 20 package(s) read outside themselves, all declared"
  • pnpm check:test-source-alias, check:nul-bytes, check:type-check-coverage, check:type-source-resolution, and the changeset gate family — all exit 0

Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack on the merged tree (20 matched + the test-file conventions), not recalled.

One ratchet went genuinely red and was repaired at the source, not at the ledger.check:type-check-debt reported @objectstack/rest TEST_DEBT 155 to 158 (+3). Reproduced the gate's program locally at 158 and attributed all three to the new suite: Object.hasOwn is TS2550 against this package's pre-es2022 lib — the same ceiling its ledger note already records as TS2550 x16 for Array.prototype.at. Rewritten as 'declaredCode' in error; the layer re-measures at exactly 155 with 0 errors in either edited file, and the gate is green. ⛔ The ledger entry was not raised.

Scope

declaredCode only. ⛔ userMessage is not threaded — it is a separate card with a different caller obligation, and observations gathered for it are reported to the PM rather than acted on here. packages/rest/src/rest-server.ts (hard-serial behind PR #12421) and packages/types/** are untouched; what was observed in the fenced file is reported, not changed.


Generated by Claude Code

…aredCode
`sendThrownError` in `packages/rest/src/package-routes.ts` resolved every throw
through the shared ADR-0112 rule and then forwarded `{ details }` and nothing
else, so the demoted spelling it was holding in the same local was dropped one
line later. It now reads `demotedDeclaredCode(thrown)` and spreads it into the
envelope writer's `extra`, which is the idiom the dispatcher twin serving this
same path (`errorFromThrown`, #9106) and the flat `/data` door
(`thrownCodeFields`, #9232) already apply.
Additive: a registered code still carries no `declaredCode`, an uncoded throw
still carries none, `details` is untouched, and `code`/`status`/`message` are
unchanged on every existing path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@github-actions

github-actionsBot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8425c17cccc604ab3305362a4bda6b6c42c29c0epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 492330ba0e735b904a50a311f9f1507dc2e3e0dd — the merge of head 424f28503fece2111d6aef434e7372b89bc611b4 into base 8425c17cccc604ab3305362a4bda6b6c42c29c0e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 492330ba0e735b904a50a311f9f1507dc2e3e0dd && git checkout 492330ba0e735b904a50a311f9f1507dc2e3e0dd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8425c17cccc604ab3305362a4bda6b6c42c29c0e 424f28503fece2111d6aef434e7372b89bc611b4 && git checkout -B drift-repro 8425c17cccc604ab3305362a4bda6b6c42c29c0e && git merge --no-ff 424f28503fece2111d6aef434e7372b89bc611b4
node scripts/docs-audit/affected-docs.mjs --json 8425c17cccc604ab3305362a4bda6b6c42c29c0e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

… rests on
Docblock only, in `package-door-declared-code.test.ts` section 4. The block
argued the CONSISTENCY half (prose-scoped withhold, the twin applies no status
condition) and left unstated the fact the decision actually rests on: no
producer reaching these four seams can put a driver errno in `declaredCode`,
because `PackageService` discriminates on the STATUS channel and never on
`.code`. That is what makes not-withholding safe rather than merely consistent,
and it is a premise that can rot, so it is now named alongside what would
falsify it.
Proved comment-only: `ts.transpileModule` with `removeComments` emits
byte-identical output before and after (13515 bytes, sha256 8849a4b2c9f4c197),
and the same check reports DIFFERENT when one code token is perturbed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@os-litantClaude

Copy link
Copy Markdown
CollaboratorAuthor

Addendum pushed as 424f28503docblock only, per the accept note.

Section 4's block (the demote is not withheld by the 5xx message sanitiser) argued the consistency half and left the load-bearing half unstated. Added, in the block:

  1. The premise, named. No producer reaching these four seams can put a driver errno in declaredCode, because PackageService discriminates on the STATUS channel and never on .code — quoting packages/services/service-package/src/index.ts, which says why in as many words ("every SQL driver populates a string code on its errors, so reading it would re-throw genuine driver faults as if they were refusals"). get/list re-throw only the branded seam refusal; protocol.deletePackage escapes only with TENANT_SCOPE_REQUIRED or metadataStoreUnavailableError.
  2. That this makes not-withholding SAFE, not merely consistent with the twin — and a note that the pinned case deliberately spells a producer-authored code (WIDGET_STORE_UNREACHABLE), so reading the suite as evidence that a driver dialect on the wire is fine would be reading it backwards: nothing here measures that shape, because nothing produces it.
  3. The falsifier, stated so the next reader inherits a measurement rather than an argument: a producer reaching any of these seams with a driver errno as its .code — a PackageService that re-throws on .code rather than status, a protocol slice that lets a raw driver error out of deletePackage, or a resolveExecutionContext that throws one synchronously. On that day the premise is false and the fork re-opens rather than being re-derived.

The ruling itself is recorded with its date and its ground, with B named and refused and C flagged as the separate cross-door card.

No new gate union was run, and none is owed — this is a comment-only addition to a file whose union already ran green at 5f1d5235c. Rather than assert that, it is proved mechanically: ts.transpileModule with removeComments emits byte-identical output before and after — 13515 bytes, sha256 8849a4b2c9f4c197 both sides — so the executed program is literally unchanged. The check carries its own control: perturbing one code token in the new file makes the same comparison report DIFFERENT, so the instrument is not blind. Independently, git diff -U0 classifies 0 non-comment changed lines, and check-nul-bytes' own pattern scans clean over the file.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-litant@claude