Skip to content

fix(rest): stop answering a terminal 404 for a failed REGISTRY read on GET /api/v1/packages/:id - #11602

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-11376-package-id-registry-read
Aug 24, 2026
Merged

fix(rest): stop answering a terminal 404 for a failed REGISTRY read on GET /api/v1/packages/:id#11602
os-zhuang merged 2 commits into
mainfrom
claude/issue-11376-package-id-registry-read

Conversation

@claude

@claudeclaudeBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes#11376

What was wrong

packages/rest/src/package-routes.ts, the GET /api/v1/packages/:id handler, tries the durable sys_packages read first and falls back to the in-memory registry. The fallback sat in a bare catch:

}catch{// Protocol unavailable}

so when protocol.getMetaItems({ type: 'package' }) threw, control fell straight through to the line below and the door answered 404 RESOURCE_NOT_FOUNDPackage "<id>" was not found.

This is the worse half of the family, not a smaller one. The list door's version of the same swallow (#11130) answered a 200 whose total merely under-counted. This one answers a terminal negative fact: 404 / RESOURCE_NOT_FOUND is not "the answer may be incomplete", it is "this package does not exist", and a caller acts on it — an installer decides the package is not installed and offers to install it, a console hides the entry, a script branches to the create path. The producer's own words for the same condition are the opposite: "whether this item exists is unknown".

It is also #5532's defect resurfacing one layer up. ObjectStackProtocolImplementation.getMetaItems was taught not to report an unreadable sys_metadata as "that item does not exist"; this consumer-side catch then re-applied precisely that relabelling to the protocol's answer.

The direction is inherited, not designed here

Per the triage ruling on the card, this transplants the shape #11063 and #11130 already landed in this same file (which in turn transplant #10965 / #10677): the producer already declares the refusal — every non-benign sys_metadata overlay read failure leaves getMetaItems through rethrowUnlessMetadataStoreUnprovisionedmetadataStoreUnavailableError, i.e. SERVICE_UNAVAILABLE / 503 with an ADR-0112 status+code on the error — so the whole repair is delete the catch and let the existing sendThrownError carry the producer's own status and code. No third shape was invented and no new error taxonomy was introduced.

Standing family ruling — #10965 · #10677 / PR #10788 · #10789 / PR #10964 · #11063 · #11130: a read that could not happen must not be reported as a read that found nothing.

The pins are the DISCRIMINATION, both directions

New: packages/rest/src/package-id-registry-read-refusal.test.ts — 11 cases. Asserting only the new branch would leave "turn everything into a 500" passing, so the control half is as load-bearing as the fix.

pinned
§1a failed registry read answers 503 SERVICE_UNAVAILABLE in the declared envelope; the terminal 404 words are gone (no 404, no RESOURCE_NOT_FOUND, no "was not found"); an undeclared throw is 500 INTERNAL_ERROR, not a 404
§2CONTROL a genuine miss (both sources read fine, neither holds the id) is still 404 RESOURCE_NOT_FOUND; CONTROL an absent protocol service is an absence, not a failed read, and still reaches that same 404; CONTROL a registry hit is still 200 / source: 'registry'; CONTROL a durable hit is still 200 and never consults the registry (spied, not inferred); and the discrimination stated directly — an outage and a clean-empty registry must not answer alike
§3CONTROL the durable half already answered 503; both halves of this door agree; this door and the list door agree on the same outage

Every refusal is asserted as codeandstatus in the ADR-0112 envelope — no bare toThrow(), no status on its own. No wire field is added and none is asserted.

Anti-vacuity — predicted before running, measured after

Fix committed first, then onlypackage-routes.ts reverted to origin/main under a trap … EXIT INT TERM. Mutation proved on disk by anchored greps in both directions (an editor's exit code proves nothing; a zero-hit sed exits 0):

injected '[#11376]' = 0 (2 on the fixed tree)
removed '^ // Protocol unavailable$' = 1 (0 on the fixed tree)
removed '^ } catch {$' = 1 (0 on the fixed tree)
MUTATION CONFIRMED ON DISK

No rebuild between legs, and the claim was checked rather than assumed: the mutated symbol is reached by the relative import ./package-routes.js inside this package, which vitest transforms from source; the only exports-resolved workspace deps in the suite (@objectstack/spec/api, @objectstack/types) are untouched by the mutation. An all-green ablation leg would have been the stale-artifact signature — it is not what happened.

sectionpredictedmeasured
§13 red / 0 green3 red — as predicted
§21 red / 4 green1 red (the discrimination case) — as predicted
§32 red / 1 green2 red — as predicted
total6 red / 5 greenTests 6 failed | 5 passed (11) — as predicted

Predictions are left as written. One thing did go wrong and is recorded rather than tidied away: the trap's own restore confirmation was void on the first run — the trap fired with cwd inside packages/rest, so both the git checkout <branch> -- packages/rest/src/… restore and its git diff --quiet -- <same path> check got a pathspec relative to the wrong directory, git matched nothing, git diff reported "no differences", and the script printed RESTORED over a tree that was still mutated and staged. It was caught by re-running the greps from the repository root, and the tree was restored for real before anything else was measured. The lesson is left in the test file's header for the next ablation in this package: a restore leg confirms nothing unless its pathspec resolves.

Clause-② — measured: no

The card changes what a caller receives when a registry read fails. Three legs, measured rather than inherited from the family precedent:

  1. Accept set unchanged. No input that was refused is now accepted and none that was accepted is now refused — only the answer on a failed read moves. Measured: the four §2 controls are green on both ablation legs, i.e. every input whose answer was already correct keeps a byte-identical answer.
  2. No declared surface widened. No wire field, no new route, and packages/spec enumerates no response/status set for this route — packages/spec/src/api/package-api.zod.ts declares request/response bodies and no HTTP statuses, and the ADR-0112 error-code ledger names RESOURCE_NOT_FOUND only as a shadows entry, not as a per-route enumeration.
  3. No status new to the route.503 and 500 were already reachable on this exact route pre-fix, through the durable read (PackageService.get() → outer catch → sendThrownError, since service-package answers "no such package" / "no packages installed" over a driver it never queried — its own normalizeRows maps a non-answering seam onto zero rows #10965). Measured by the §3 control, which is green on both ablation legs.

⇒ a wrong answer replaced by a right one; accept set unchanged. needs:contract-review not applied. PR left draft, no ready-flip, no auto-merge, no enqueue.

One file beyond the declared surface, declared

packages/rest/src/package-door-5xx-message-sanitization.test.tscomment only, no assertion touched. Its §2 header enumerates which sources in this registrar reach the outer catch, and carried "Still true of the REGISTRY source: protocol.getMetaItems keeps its own inner catch". That is already false in main (#11130 removed the list door's and did not update the note — verified against PR #11378's own changed-files list), and this card makes it false for the second and last registry read. Bounded in-place exemption, all four conditions measured: same defect class (a statement about the very catch being deleted), mechanical with the correct shape fixed by the tree's own code, no other claim on it (#11590 is the only other in-flight packages/rest PR and its changed-files list does not include it), same derived gate family with no new verification surface. Declared on the card at #issuecomment-5392195396 before push.

Verification — all at fc2f9a72, worktree clean, each gate quoted by its own verdict line

pnpm --filter @objectstack/rest typecheckTYPECHECK_EXIT=0
packages/rest suite → SUITE_EXIT=0 · Test Files 141 passed (141) · Tests 2239 passed (2239)
pnpm lint (repo-wide eslint . --no-inline-config) → os-verify-lock: VERDICT command-exit 0, no findings

Gate set derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (gate list derived from the tree of 'objectstack-ai/objectstack' at commit fc2f9a72; 4 paths vs merge base 2dc0a770b), not from a hand-built list. All 23 run to a real verdict, none refused:

17 path-derived + 6 convention-triggered — each gate's own verdict line
check:changeset-gate-self-tests EXIT=0 ✓ 118 + 212 + 116 assertions over real temp git repos
check:cross-package-test-inputs EXIT=0 OK: 16 package(s) read outside themselves, all declared,
and turbo.json hashes every declared glob
check:dispatcher-error-vocabulary EXIT=0 OK — 21 unregistered code-stamping site(s), all classified
check:objectui-changeset EXIT=0 ✓ objectui-range --self-test: all checks passed
check:published-files EXIT=0 ✓ 69 publishable package(s) of 78 …
check:route-envelope EXIT=0 ✓ 4 module(s) audited, 12 hand-built bodies:
2 conformant, 2 ratcheted, 0 exempt
check:slot-lookup EXIT=0 ✓ ratchet holds: 107 unswept site(s), none new;
baseline key set verified against 2dc0a77: no files added
check:test-source-alias EXIT=0 OK — 72 packages with tests scanned
check:type-source-resolution EXIT=0 OK — 77 packages with a tsconfig.json scanned
check-adr-0087-registration.mjs EXIT=0 ✓ adds no declared-breaking changeset
check-changeset-no-major.mjs EXIT=0 ✓ This diff introduces no `major` bump
check-ci-filter-parity.mjs EXIT=0 OK: all 95 declared cross-package glob(s) covered
check-cross-package-test-inputs.mjs EXIT=0 OK: 16 package(s) …, all declared
check-empty-changeset.mjs EXIT=0 ✓ No empty-frontmatter changeset introduced
(1 declaring changeset added)
check-plugin-teardown-shape.mjs EXIT=0 ✓ 63 Plugin implementation(s) across 4579 source(s)
docs-audit/check-affected-docs.mjs EXIT=0
pm/release-rehearsal-clone --self-test EXIT=0 ✓ self-test passed
check:query-options-erasure EXIT=0 ✓ ratchet holds: 67 unswept non-test site(s), none new
check:type-check-coverage EXIT=0 OK — 65/78 workspace packages type-checked
check:type-check-debt EXIT=0 --re-measure: OK — 32 ledger entr(ies) re-measured in
247.5s, 1897 raw tsc error(s), none above its recorded
number [run on a BUILT closure: turbo run build
--filter=./packages/* --filter=./packages/*/* first,
70/70 tasks successful]
check:engine-double-contract EXIT=0 OK — 397 pinned, 133 in the DEBT ledger, 2 exempt
check:where-matcher EXIT=0 ✓ 292 matcher(s) discovered, 292 correct or refuse loudly;
0 silently-wrong; none new
check:nul-bytes EXIT=0 OK (scanned 6491 text file(s); no raw ASCII control bytes)

Exit codes captured before any pipe (cmd > file 2>&1; ec=$?), never off a tail. check:type-check-debt reports two pre-existing -1 surpluses (plugin-approvals, runtime) as informational; neither is in this diff's packages and the gate exits 0.

⚠️ Per the dispatch contract, this report is filed at draft-PR time; CI convergence is the PM's read, not a wait held here.


Generated by Claude Code

zhuangjianguoand others added 2 commits August 24, 2026 07:05
…n GET /api/v1/packages/:id (#11376)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
…confirmation (#11376)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 5 documentable anchor(s).

15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via packages.uninstall (sdk), /packages/:id (route))
  • content/docs/api/metadata-api.mdx(via /packages/:id (route))
  • content/docs/automation/approvals.mdx(via /packages/:id (route))
  • content/docs/data-modeling/formulas.mdx(via /packages/:id (route))
  • content/docs/deployment/publish-and-preview.mdx(via /packages/:id (route))
  • content/docs/deployment/self-hosting.mdx(via /packages/:id (route))
  • content/docs/kernel/contracts/metadata-service.mdx(via /packages/:id (route))
  • content/docs/permissions/access-recipes.mdx(via /packages/:id (route))
  • content/docs/permissions/authentication.mdx(via /packages/:id (route))
  • content/docs/permissions/permission-sets.mdx(via /packages/:id (route))
  • content/docs/permissions/record-view-auditing.mdx(via /packages/:id (route))
  • content/docs/plugins/adding-a-metadata-type.mdx(via /packages/:id (route))
  • content/docs/plugins/packages.mdx(via /packages/:id (route))
  • content/docs/ui/actions.mdx(via /packages/:id (route))
  • content/docs/ui/audience-based-interfaces.mdx(via /packages/:id (route))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx(via /packages/:id (route))
  • content/docs/releases/v17.mdx(via /packages/:id (route))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

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 6d3550961f57d2b5efcbb5de5dd45c0090363d03packageMentionDocs.

Which tree this was computed on

This run read content/docs from f3da516d8a9fcfeae791afef678328d756bc08e4 — the merge of head fc2f9a72988b496d8dbfc5f5447fa3487f0030db into base 6d3550961f57d2b5efcbb5de5dd45c0090363d03, 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 f3da516d8a9fcfeae791afef678328d756bc08e4 && git checkout f3da516d8a9fcfeae791afef678328d756bc08e4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6d3550961f57d2b5efcbb5de5dd45c0090363d03 fc2f9a72988b496d8dbfc5f5447fa3487f0030db && git checkout -B drift-repro 6d3550961f57d2b5efcbb5de5dd45c0090363d03 && git merge --no-ff fc2f9a72988b496d8dbfc5f5447fa3487f0030db
node scripts/docs-audit/affected-docs.mjs --json 6d3550961f57d2b5efcbb5de5dd45c0090363d03

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6d3550961f57d2b5efcbb5de5dd45c0090363d03 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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

Development

Successfully merging this pull request may close these issues.

GET /api/v1/packages/:id swallows the same failed REGISTRY read — and answers a terminal 404 RESOURCE_NOT_FOUND for it

2 participants

@os-zhuang@zhuangjianguo