Skip to content

fix(runtime)!: a package export refuses when the read of a type the runtime serves fails, naming the package and the type - #29824

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-gnh1j1
Nov 3, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-gnh1j1

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Warren · project thread
Fixes #29760

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — the skills and docs teach what a package export carries, never how it fails; the refusal is a retryable server answer whose message names the type, and nothing a metadata author writes is read differently.

维护者速读

  • 改了什么:assemblePackageManifest(packages/runtime/src/domains/packages.ts)逐类型读取的 catch 不再一律当「未知类型」跳过。只有运行时实时类型表(getMetaTypes)里没有的类型照旧跳过;运行时服务的类型读取失败,则拒绝整个导出,消息点名包与类型,并沿用读取错误自带的 status/code(实测存储故障为 503 SERVICE_UNAVAILABLE,未声明的抛错为 500)。整库导出 GET /packages/export 组合同一生产者,随之拒绝,无第二处检查。
  • 为什么改:改前读取失败时 GET /packages/:id/export 照样答 200,只是少了整个集合(实测 showcase 少了 dashboards),据此重装会静默丢掉该类型,违背导出权承诺与 bundle 的「要么完整、要么没有」。
  • 风险与回滚:收窄(BREAKING 横幅):原本答 200 的残缺导出现在是 5xx 拒绝。读取成功时导出逐字节不变;没有 getMetaTypes 或类型表读不出来时按「已服务」处理(失败即拒绝,fail closed)。回滚即 revert 本 PR。
  • 先看:① packages.ts 的 runtimeServesMetadataType 与 packageExportTypeReadRefusal(判定与拒绝的构造);② 逐类型循环里改写后的 catch;③ dogfood package-export-type-read-failure.dogfood.test.ts 的注入点(协议自身的 readActiveOverlayRows)。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/domains/packages-export-type-read-failure.test.ts,预期 11/11 通过;撤掉修复后 8 条转红(读到的是 200 而非拒绝)。
  • 需要你做的:无,席位落地(Clause-② 收窄,席位先做 CONTRACT_REVIEW_TIER 复核)。

What changes

  • assemblePackageManifest's per-type catch (packages/runtime/src/domains/packages.ts). It caught every failure of getMetaItemsPerPackage / getMetaItems as "unknown/unsupported type for this runtime" and skipped the type. Now, after a read throws, it asks one question: does this runtime serve the type?
    • No (the type is absent from the live type vocabulary, getMetaTypes, each listed name folded through PLURAL_TO_SINGULAR, so a plural spelling counts; the one accessor that answers "which types exist here" for the /meta listing too): skipped, as before.
    • Yes: the export is refused. packageExportTypeReadRefusal builds the error: the message names the package and the type (Reading the 'dashboard' items of package 'P' failed, so its export is refused rather than answered without them), the read's own declared status / statusCode and string code ride on unchanged, and the read's error stays on cause. The read's sentence is quoted only when it is a declared client refusal (clientFacingFailureText, the existing ADR-0112 disclosure decision this file already imports).
    • Fail closed: a protocol that brings no getMetaTypes, or whose listing throws, has not shown the type unknown, so its failed read refuses too. The listing is asked only after a read threw; a successful export never calls it.
  • Status family, no new code. The per-package route's existing catch answers errorFromThrown(e, 500), so the refusal is the read's declared status and code, or 500 INTERNAL_ERROR for an undeclared throw. Measured on the real protocol: a failed overlay read is rethrown as a declared 503 SERVICE_UNAVAILABLE, and the export answers exactly that.
  • The bundle. GET /packages/export (feat(runtime,client,cli): export a deployment's whole ontology as installable packages in one read (GET /packages/export, os package export) #29755) composes assemblePackageManifest and already turns a throw into a refusal naming the package. So it refuses through the per-package path, naming the package and the type, with no second check in the bundle (the one-producer rule).
  • PackagesDomainProtocol picks getMetaTypes from the declared MetadataProtocol contract, beside the three it already picked. No packages/metadata-protocol/src/protocol.ts and no spec file is touched.
  • Changeset: @objectstack/runtime patch, fix(runtime)!: with the BREAKING banner, both declaration lines and the ADR-0087 disposition not-required (no-migration-prescription).

Tests

  • New unit file packages/runtime/src/domains/packages-export-type-read-failure.test.ts (real dispatcher, protocol double), 11 cases:
    • per-package export: undeclared throw → 500 INTERNAL_ERROR naming package and type, the throw's sentence not quoted; declared 503 SERVICE_UNAVAILABLE carried; a declared 400 INVALID_REQUEST's sentence quoted after the package and the type; CONTROL a type the vocabulary does not list still skipped, the rest answered 200; fail closed with no getMetaTypes and with a throwing listing; CONTROL the listing is not asked when no read throws; a vocabulary listing the type only by its plural still serves it, so the failed read refuses;
    • bundle: declared 503 and undeclared 500 refuse GET /packages/export, naming package and type, no body; CONTROL an unserved type leaves the bundle 200 with the entry.
  • New dogfood file packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts, one showcase boot with the install-local plugin and one installed package, the fault injected on the real protocol's own overlay read (readActiveOverlayRows), 5 cases: CONTROL both doors 200 with dashboards; a failed dashboard read refuses GET /packages/com.example.showcase/export 503 SERVICE_UNAVAILABLE naming both, no manifest; the same refuses GET /packages/export naming the installed package and dashboard, no bundle; CONTROL restored, both 200 again; an unserved type (rag_pipeline, asserted absent from the boot's live vocabulary) still skipped, both doors 200 with the same collections.
Red first, before the fix
  • Unit, on origin/main 7ba8b11f (the fix's source restored from the base): 6 of 7 failed, every one reading 200 ({"success":true,"data":{"objects":[…],"id":"com.example.ledger",…}}, no dashboards). The seventh, the no-throw CONTROL, passed.
  • Unit, after merging origin/main with feat(runtime,client,cli): export a deployment's whole ontology as installable packages in one read (GET /packages/export, os package export) #29755 (b3d14150), the fix commit reverse-applied in the tree: 8 of 10 failed, the two bundle refusal cases among them (bundle 200).
  • Dogfood, the runtime rebuilt without the fix (ablation-dist-preflight proved the marker absent from packages/runtime/dist; restore leg rebuilt and proved it present, tree clean): the refusal case failed with expected 200 to be 503; the body carried 20 collections and no dashboards key.
Green, on the final head `725594f6`

Acceptance notes

  • The unknown arm on a real boot. Measured on the showcase: 30 types are exportable and the live vocabulary lists 37; exactly one exportable type is absent, rag_pipeline. That is the only type whose failed read a real showcase still skips; every other per-type failure now refuses. A protocol double without the type, or without getMetaTypes at all, is the other place the arm is reachable.
  • Why the vocabulary, and not the error's shape. The real protocol never throws for a type it does not know (an empty registry slot answers [], and an unsupported metadata-service loader is caught inside the protocol), so no error shape marks "unknown type". Asking the runtime's own type listing, only after a throw, is the one authority available without protocol.ts (frozen).
  • Message disclosure. The sentence names the package and the type in every case; the read's own text is appended only for a declared 4xx (clientFacingFailureText), so a driver dump never reaches the wire through it and the dispatcher's 5xx withhold never swallows the package and type names.
  • Out of scope, noted. ownDocumentsOf's catch around registry.getArtifactItem and the registry.getPackage lookup's catch in the same function still answer undefined on a throw; neither drops a collection (the first keeps the layer's items, the second only loses the label/namespace lookup). Read-only inference; carrier: none.
  • Commit identity. Commits carry the model-free trailer pair; the harness's attribution reminder names a model in its Co-Authored-By line, which the pre-push hook refuses, so the AGENTS.md form was used.
  • Reviewers. The dev requested none.

Verification

  • node scripts/pm/dispatch-gates.mjs --commands on this diff (head 725594f6, 4 paths): 63 commands, all run, every exit 0 after the check:type-check-debt lock re-run (as on 4378aebe, merge base b3d14150, +452 / -4). check:type-check-debt first answered exit 3 (PREREQUISITE NOT MET: its closure build queued behind this PR's own dogfood battery on the shared lock and timed out, nothing measured); re-run under the lock, exit 0 (0 ledger entries above their recorded numbers).
  • --ran reconciliation: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN.
  • The same 63 on the pre-merge head cac7e010: 61 exit 0 at first; check-plugin-teardown-shape --self-test exit 3 (its pinned positive-control commit was outside the shallow clone; fetched, re-run exit 0) and check:dual-build-cjs-loads exit 3 (PREREQUISITE NOT MET, unrelated packages had no dist/; exit 0 on the final head).
  • pnpm lint (repo-wide eslint, --no-inline-config) on 4378aebe: exit 0; CI's Lint family is green on 725594f6.
  • NOT MEASURED locally, CI-owned: the artifact-roster and wide-population families, the type-check lanes and the always-runs tail; this PR's check-runs are the reading for those.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No schema touched.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (pnpm lint exit 0 on 4378aebe.)
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (Not touched.)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English. (The 维护者速读 block is the os-dev maintainer brief, in Chinese by that file's rule.)
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…ime serves fails

assemblePackageManifest skipped every per-type read failure as an unknown
type, so GET /packages/:id/export answered 200 without the collection. Only a
type absent from the runtime's live vocabulary (getMetaTypes) is skipped now;
a failed read of a served type refuses the export, naming the package and the
type, with the read's declared status and code carried.

Claude-Session: https://claude.ai/code/session_01JQwxbXpF6XoEdQQbPsGPBQ
Co-authored-by: Claude <noreply@anthropic.com>
…ckage export on a failed read of a served type

Claude-Session: https://claude.ai/code/session_01JQwxbXpF6XoEdQQbPsGPBQ
Co-authored-by: Claude <noreply@anthropic.com>
…iled read of a served type

Claude-Session: https://claude.ai/code/session_01JQwxbXpF6XoEdQQbPsGPBQ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/concepts/metadata-lifecycle.mdx (via getMetaItem (literal, a string literal in Partial), getMetaItems (literal, a string literal in Partial), saveMetaItem (literal, a string literal in Partial))
  • content/docs/data-modeling/drivers.mdx (via getMetaItem (literal, a string literal in Partial))
  • content/docs/deployment/environment-variables.mdx (via getMetaItem (literal, a string literal in Partial), getMetaItems (literal, a string literal in Partial))
  • content/docs/deployment/validating-metadata.mdx (via saveMetaItem (literal, a string literal in Partial))
  • content/docs/kernel/cluster.mdx (via saveMetaItem (literal, a string literal in Partial))
  • content/docs/kernel/services-checklist.mdx (via getMetaItem (literal, a string literal in Partial), getMetaItems (literal, a string literal in Partial), getMetaTypes (literal, a string literal in Partial), saveMetaItem (literal, a string literal in Partial))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (literal, a string literal in Partial))
  • content/docs/plugins/adding-a-metadata-type.mdx (via getMetaTypes (literal, a string literal in Partial))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 28 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 42ff86f67723f5cd6b275bc4ddbb10f05e8c4209 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f71ab18addf57f66d101abc6b7e7b2a4bf21144c — the merge of head 725594f6196338ffd9149b6c8b82907446708b73 into base 42ff86f67723f5cd6b275bc4ddbb10f05e8c4209, 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 f71ab18addf57f66d101abc6b7e7b2a4bf21144c && git checkout f71ab18addf57f66d101abc6b7e7b2a4bf21144c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 42ff86f67723f5cd6b275bc4ddbb10f05e8c4209 725594f6196338ffd9149b6c8b82907446708b73 && git checkout -B drift-repro 42ff86f67723f5cd6b275bc4ddbb10f05e8c4209 && git merge --no-ff 725594f6196338ffd9149b6c8b82907446708b73

node scripts/docs-audit/affected-docs.mjs --json 42ff86f67723f5cd6b275bc4ddbb10f05e8c4209

⚠️ 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 42ff86f67723f5cd6b275bc4ddbb10f05e8c4209 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-warren · session_01JQwxbZUR3xvHyDPCjNL5P4

Contract-review-claim: session_01JQwxbZUR3xvHyDPCjNL5P4 · head 4378aeb · 2026-11-03T06:22Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-warren · session_01JQwxbZUR3xvHyDPCjNL5P4

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4378aebe312c4b5673c3c3963424706f07f62aee
Local-runs: none
Scope: packages/runtime/src/domains/packages.ts, packages/runtime/src/domains/packages-export-type-read-failure.test.ts, packages/qa/dogfood/test/package-export-type-read-failure.dogfood.test.ts, .changeset/29760-export-type-read-failure.md (read for ②, not reviewed) + packages/runtime/src/http-dispatcher.ts (errorFromThrown, error, resolveService), packages/metadata-protocol/src/protocol.ts (getMetaTypes, listLiveMetadataTypes, rethrowUnlessMetadataStoreUnprovisioned, getMetaItemsPerPackage), packages/metadata-protocol/src/protocol-live-metadata-types.ts, packages/metadata-protocol/src/protocol-meta-list-reads.ts (readFlattenedMetaItems), packages/metadata-protocol/src/protocol-meta-type-schema.ts (canonicalizeMetaRequestType), packages/metadata-protocol/src/protocol-client-failures.ts (clientFacingFailureText, declaresClientRefusal, metadataReadFailureError, metadataStoreUnavailableError), packages/types/src/error-leak.ts (looksLikeInternalErrorLeak), packages/spec/src/api/package-lifecycle.zod.ts (PackageExportBundleSchema, PackageExportManifestSchema), packages/spec/src/meta-spelling/manifest-collection-spelling.ts (PLURAL_TO_SINGULAR), packages/objectql/src/registry.ts (registerItem, getRegisteredTypes, getPackage), packages/objectql/src/engine.ts (registerApp fold), packages/objectql/src/metadata-facade.ts, packages/metadata/src/metadata-manager.ts (getRegisteredTypes), packages/cli/src/commands/package/export.ts, packages/client/src/index.ts (packages.export, packages.exportAll), ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx (exportPkg), packages/runtime/src/domains/packages-export-bundle.test.ts and the six sibling export test files (checked for reliance on the old answer)

Inputs read: card #29760 body and every comment (triage audit 6595093120, claim 6595325946, os-dev-report 6596938569); PR #29824 body, file list (4 paths, +452/-4) and the net diff against main at the head (merge base b3d1415); the head's 46 check-runs. Judged on the tree, not the PR body's account.

Check-runs on the head at review time: concluded success — Check Changeset, Governed Surface Queue Guard, PR-body guards, Check PR Size, Lint · ESLint, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Type Check · debt ledger, Spec property liveness, Flag docs affected, Plan Test Core shards, Test UI, Dogfood Regression Gate, Auto Label, filter; still in_progress — Test Core 1/8 through 8/8, Build Core, Type Check · source gates, Type Check · workspace, Type Check · consumer gates, Lint · docs & code guards, Lint · CI release & engine gates, Lint · repo & tooling gates, Lint · scoped · slot lookup & corpus, Lint · scoped · query-options erasure, Live E2E UI; the rest skipped by path filter. ⚠️ The derived gate families are PENDING: their conclusions are the gate verdicts, this record does not stand in for them, and nothing lands before they conclude. None has failed.

① Derived judgments

Accept-set changes the diff implies, each judged against the live code:

  1. GET /packages/:id/export, a served type's read throws → refused, no body. RIGHT. On main the per-type catch { continue } (assemblePackageManifest) dropped the collection and answered 200. At the head the catch asks runtimeServesMetadataType(protocol, singular); a listed type rethrows packageExportTypeReadRefusal(packageId, singular, e), which leaves the route through handlePackagesRequest's outer catch (try at line 1371, catch at 3149) into deps.errorFromThrown(e, 500): the same family every other /packages catch answers through. No new code is minted.
  2. Status and code ride on from the read; an undeclared throw is 500 INTERNAL_ERROR. RIGHT. packageExportTypeReadRefusal copies a numeric status / statusCode and a non-empty string code onto the new error; resolveThrownHttpError reads exactly those. The real store fault is the protocol's own classification: rethrowUnlessMetadataStoreUnprovisioned → metadataReadFailureError → metadataStoreUnavailableError, which sets status = 503, code = 'SERVICE_UNAVAILABLE'. So the export answers 503 for a store fault, which is the route's existing refusal family for that fault, measured on the real protocol by the dogfood file. Against the route's family this is the right status: retryable, and errorFromThrown is unchanged.
  3. The message names the package and the type, and reaches the wire. RIGHT. The sentence is Reading the 'TYPE' items of package 'ID' failed, so its export is refused rather than answered without them; the read's own text is appended only when declaresClientRefusal (a declared 4xx) through clientFacingFailureText(cause, ''). On a 5xx the message therefore never carries driver text, so this.error's looksLikeInternalErrorLeak withhold (sqlite_/sqlstate/SQL verbs/constraint phrasings) cannot swallow the package and type names; the read's error stays on cause, which the resolver does not read and the envelope does not serialise.
  4. A type the runtime does not serve is skipped as before. RIGHT, within the residue named in ④. getMetaTypes().types is listLiveMetadataTypes(): the SchemaRegistry's item keys plus the metadata service's typeRegistry (seeded with DEFAULT_METADATA_TYPE_REGISTRY) and custom-type keys. A singular absent from both is continued, with the rest of the export answered (unit CONTROL, dogfood case 4 on rag_pipeline).
  5. Fail closed when getMetaTypes is missing or throws. RIGHT. typeof protocol.getMetaTypes !== 'function' → served; a throw → served; a non-array types → served. The listing's own failure is not reported; the read's is. No in-tree protocol other than ObjectStackProtocolImplementation implements getMetaItems (the MCP, REST and spec hits are interfaces or callers), so the arm is reachable only by doubles.
  6. A successful export is byte-identical. RIGHT. runtimeServesMetadataType is called inside the catch only; the unit CONTROL pins getMetaTypes not called when no read throws.
  7. GET /packages/export refuses through the same producer. RIGHT. assemblePackageExportBundle (on main since feat(runtime,client,cli): export a deployment's whole ontology as installable packages in one read (GET /packages/export, os package export) #29755) wraps each assemblePackageManifest call: a throw is answered deps.errorFromThrown(e, 500) with the message prefixed The export of package 'ID' could not be assembled, so no bundle is answered: …. The prefix is applied after the withhold decision, so the package name survives on any status. Whole-or-absent (PackageExportBundleSchema docblock) now holds for a per-type failure as well as for a per-package one; no second check was added in the bundle (the one-producer rule).
  8. PackagesDomainProtocol picks getMetaTypes from MetadataProtocol. RIGHT and not a public-surface change: the alias is module-local to packages/runtime/src/domains/packages.ts (not exported from the runtime's index.ts), Partial, and the member is read defensively. protocol.ts and the spec are untouched.
  9. canonicalizeMetaRequestType's 400 is unreachable here. RIGHT. Every singular the loop asks is a PLURAL_TO_SINGULAR value or object_extension, all declared spellings, so the only throws the catch meets on the real protocol are the store classification (503) and undeclared faults (500); an unsupported metadata-service loader is caught inside readFlattenedMetaItems (its merge catch at line 642), as the PR body claims and the tree confirms.
  10. Consumers. No consumer depends on the old 200-thinner answer. os package export (cli/.../package/export.ts) catches the refusal, prints status and code, and writes nothing, which is what its header promises. client.packages.export(id) and exportAll() throw through unwrapResponse on a non-200. The Console's exportPkg (PackagesPage.tsx) runs apiJson under run(), so the refusal is shown instead of a thinner file being downloaded. The seven existing runtime export test files mock no throwing per-type read except packages-export-bundle.test.ts's whole-or-absent case, which is the behaviour kept. Dogfood export tests (package-export-bundle-round-trip, package-export-reinstall-custom-pair) read on boots whose reads succeed.
  11. A transient fault now refuses every export, and the bundle. RIGHT by the spec: the export is the package's whole content or no answer, and the bundle is whole or absent; a 503 is the retry signal, and the message names the type to look at. The alternative, a 200 with a hole, is the defect this card names.

② Semver level

Changeset .changeset/29760-export-type-read-failure.md: @objectstack/runtime at patch, title fix(runtime)!:, a BREAKING banner with before / now / unchanged / who-sees-it / remedy, exactly one ADR-0087 marker adr-0087: not-required (no-migration-prescription), and both declaration lines. AGENTS.md rule 3: (narrowing) is BREAKING, and a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition. The diff publishes exactly that: a behaviour narrowing of @objectstack/runtime with no key, value or type an author writes read differently, so patch is the right level and Metadata-protocol: none is right. Check Changeset concluded success on the head.

Clause-②: Clause-②: no (narrowing) is on the PR body and in the changeset body, line-initial, one arm from the closed pair. It matches the diff: the door answers fewer requests with a body and nothing is widened (a formerly-200 thinner answer is now a refusal; every answer that was whole is unchanged). The claim comment carries the same line.

③ Boundary flags

  • open_questions: []. Nothing to answer.
  • Dev deviation, commit trailers: the commits carry the hook-required model-free trailer pair rather than the harness reminder's model-named line. A hook rule over the tree's own history; not a contract matter. Answered.
  • Dev deviation, pnpm lint run though CI-owned: exit 0, no effect on the tree. Answered.
  • Out-of-scope finding 1 (carrier none): ownDocumentsOf's catch around registry.getArtifactItem and the registry.getPackage catch answer undefined on a throw. Read: getPackage is a synchronous in-memory map read (registry.ts:8046, this.metadata.get('package')?.get(id)), and getArtifactItem is the same registry; neither can fault the way a store read does, and neither drops a collection (a lost namespace / dependencies or a 404 for an item-less package would be the same class of silent thinning, but has no measured producer). Not blocking; the seat may file it as a hygiene card. Answered.
  • Out-of-scope finding 2 (carrier none): on a showcase boot exactly one exportable type, rag_pipeline, is absent from the live vocabulary. Verified in source: the plugin kinds (webhook, connector, analytics_cube, rag_pipeline) have no DEFAULT_METADATA_TYPE_REGISTRY entry, so they enter the vocabulary only once an item is registered. This is the design's accepted residue, named in ④. Answered.
  • Docs Drift Check (advisory, 8 pages): every anchor is a string literal of the PackagesDomainProtocol Partial-Pick alias (getMetaItem, getMetaItems, getMetaTypes, saveMetaItem), not a documented behaviour. I searched content/docs for prose teaching the export's unknown-type skip or a thinner answer: none. The PR body's Skills-docs: none stands. Answered.
  • Security findings: none declared; none found. The refusal discloses only a package id, a type name and, on a declared 4xx, the producer's own sentence.

④ Design judgment

The narrowing, read against the published spec on main (PackageExportManifestSchema: the export is the package's portable, installable manifest; PackageExportBundleSchema: one producer, "whole or absent", "⛔ never a bundle that silently lacks a package") and the cited code:

It makes the spec's own refusal real; it takes no declared capability away at the consumer. The spec never declared "an export minus a collection"; the old catch delivered one by accident, and an install from it would land without the type with no word. The head draws the line where the card asked (wanted #1): the one throw the old catch may keep swallowing is a type this runtime does not serve, and the authority for "serves" is the runtime's own live vocabulary, the one accessor the /meta/types listing already answers from. The failure arm is the route's existing family through errorFromThrown, carrying the read's own classification, so a store fault answers the 503 it answers everywhere else and the whole-or-absent rule now covers the per-type case on both doors.

Attempts to break it, each answered by the tree:

  • Can the unknown-type skip still cover what the old catch legitimately covered? On the real protocol nothing legitimate was covered: an unknown type answers [] from the registry (listItems with the plural fallback) and the metadata-service merge is caught inside the read, so the old arm was reachable only by doubles. A plugin-contributed type is in the SchemaRegistry the moment it has an item, so its failed read refuses, which is right (it is served). A disabled plugin's type that no longer has an item and no static registry entry leaves the vocabulary, and its read answers [] rather than throwing; a store-wide fault reaches object (the first key) and refuses before any such type is asked.
  • Can a transient fault break every export? Yes, and that is the declared contract; the 503 is the retry signal and the message names the type. The bundle refuses the same way, naming the package, per its own docblock.
  • Is the status right against the route's refusal family? Yes: errorFromThrown(e, 500) with the read's declared status and code, unchanged; 500 only for an undeclared throw, and such a throw's text is never quoted, so the 5xx withhold cannot blank the package and type.
  • Does the bundle refuse through the same producer and keep whole-or-absent? Yes, with no second check; the prefix is added after the envelope is built.
  • Does any consumer depend on the thinner 200? None found (①.10).
  • Fail-closed trap through a wrapped protocol? resolveProtocol returns the kernel's protocol service (the full class, which has getMetaTypes); the object literal near protocol.ts:1270 is the moved-body host, not what is served.

Findings:

  • Should-fix (not blocking): runtimeServesMetadataType compares the canonical singular against getMetaTypes().types as returned, while that accessor's own contract (listLiveMetadataTypes docblock) says spellings come "as each source stores them (singular or plural) — callers normalise through PLURAL_TO_SINGULAR". registerItem is a public seam that stores the caller's spelling (metadata-facade.ts:122/200, plugin.ts:3215), so a registry holding only a plural key for a type would read as unserved and its failed read would still be skipped. Fold each listed spelling through PLURAL_TO_SINGULAR[t] ?? t before includes. Bounded: registerApp (engine.ts:9640) folds to singular, and every boot with the metadata service lists all declared types singular from DEFAULT_METADATA_TYPE_REGISTRY, so no measured path reaches it.
  • Noted, not a finding: the vocabulary is item-driven for the plugin kinds with no static registry entry, so a type-specific fault on an item-less such type (rag_pipeline on the showcase) is still skipped. That is the residue the card's "unknown or unsupported type: skip" accepts, and a store-wide fault never reaches it.
  • Noted, not a finding: getMetaTypes builds a JSON schema per live type on each call; it runs only after a read threw, once per failing type, so the cost sits on the refusal path alone.

No must-fix.

Implemented-by: claude/project-thread-gnh1j1
Reviewed-by: session_01JQwxbZUR3xvHyDPCjNL5P4

VERDICT: PASS

…he served check

getMetaTypes lists each type as its source stores it, singular or plural;
the export loop asks with the canonical singular, so a plural-only listing
read as unserved and a failed read of that type was skipped.

Claude-Session: https://claude.ai/code/session_01JQwxbXpF6XoEdQQbPsGPBQ
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-warren · session_01JQwxbZUR3xvHyDPCjNL5P4

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 725594f6196338ffd9149b6c8b82907446708b73
Local-runs: none
Scope: packages/runtime/src/domains/packages.ts (runtimeServesMetadataType fold, commit 2fd8f6f; the merged tail of assemblePackageManifest, merge 725594f), packages/runtime/src/domains/packages-export-type-read-failure.test.ts (the plural-only vocabulary case) + packages/spec/src/meta-spelling/manifest-collection-spelling.ts (PLURAL_TO_SINGULAR keys and values, its own-property note), packages/runtime/src/domains/packages-export-withheld-maps.test.ts and withheldConnectorMaps at the head (#29746, e756782, composed by the merge), packages/runtime/src/http-dispatcher.ts (errorFromThrown)

Delta review over the record on 4378aebe (comment 6597124729, PASS). Inputs: the branch from 4378aebe to 725594f6 (one fix commit 2fd8f6f and one merge of origin/main at 42ff86f), the net PR diff against that merge base (the same 4 paths, +464/-4), the round-2 os-dev-report on the card (6598945104), and the 49 check-runs on 725594f6. Judged on the tree.

Check-runs on 725594f6: 49 runs, 36 concluded success, 13 skipped by path filter, none pending, none failed. The derived gate families that were pending on the first head (Test Core 1–8/8, Build Core, the three Type Check lanes, the five Lint lanes, Live E2E UI) have all concluded success on this head.

① Derived judgments

  1. The fold closes the should-fix. RIGHT. runtimeServesMetadataType now answers whether some listed string entry, folded through PLURAL_TO_SINGULAR[listed] ?? listed, equals type, the canonical singular the loop asks with. A vocabulary that lists a type only under its plural (dashboards) now reads as served, so its failed read refuses; the new unit case (['objects', 'dashboards'], a failed dashboard read → 500 naming package and type) pins it. The accept set over the first head changes in exactly that one direction: a plural-only listing moves from skip to refuse. Nothing else moves; every case the first record judged (served singular → refuse, absent → skip, no getMetaTypes / throwing listing / non-array → fail closed, listing asked only after a throw) is unchanged, and the non-array guard is now a separate early return true with the same meaning.
  2. No new mis-skip. RIGHT. A listed singular is not a PLURAL_TO_SINGULAR key (I checked the table: the 31 keys are all plural spellings, none equal to any value or to object_extension), so a listed singular folds to itself and is matched as before. A non-string entry is ignored, which is the only sane reading of a malformed listing; the arm is fail-closed anyway, since the type is then served only if another entry names it, and a listing with no string entries at all answers "not served" only for a type nothing lists, the same as on the first head.
  3. No new mis-refusal. RIGHT. A listed plural with no PLURAL_TO_SINGULAR entry (policies, or any off-table name) stays as written and compares against a canonical singular from the table or object_extension, so it can never equal type. A prototype-member spelling (constructor, toString, __proto__) folds to a function or object (the table's own own-property note), which is never === type; harmless, and no exportable type is spelled that way. The fold can mark a type served only when a listed name IS that type's plural, which is a served type.
  4. Merge resolution. RIGHT. git show --cc 725594f6 has no resolution hunk on packages.ts (a clean merge), and the change the merge brought to packages.ts (2fd8f6f..725594f: the import, the PackagesDomainProtocol pick of getMetaItemForExecution, and the withheldConnectorMaps tail) is byte-for-byte the change main carried between the previous and the new merge base (b3d1415..42ff86f, feat(runtime): a package export names each connector map it withheld; install-local names each as incomplete #29746's hunk). The runtime: a package export silently drops a whole metadata type when its per-package read throws, so the export (and the whole-deployment bundle) can be thinner with no error #29760 hunks (runtimeServesMetadataType, packageExportTypeReadRefusal, the loop's catch) are intact at the head, and the net PR diff against the merge base is still the PR's four paths.
  5. The two features compose: a known-type read failure refuses before withheldConnectorMaps runs. RIGHT. In assemblePackageManifest at the head the per-type loop (lines 3564–3588, the catch that throws packageExportTypeReadRefusal) runs to completion before the registry lookup, the four manifest keys and, last, withheldConnectorMaps(protocol, packageId, manifest.connectors) at line 3675. A served type's failed read throws out of the loop, so the execution-face read is never asked and no withheldMaps verdict is guessed for a refused export.
  6. withheldConnectorMaps' own failures stay its own refusal. RIGHT. Its three throws (no connector redactor, no execution face, a connector the face answers no body for) are plain Errors thrown outside the loop's try, so the runtime: a package export silently drops a whole metadata type when its per-package read throws, so the export (and the whole-deployment bundle) can be thinner with no error #29760 catch never sees them; they leave through the route's outer catch as errorFromThrown(e, 500) → 500 with feat(runtime): a package export names each connector map it withheld; install-local names each as incomplete #29746's own sentence naming the package. A throw FROM getMetaItemForExecution (a store fault) likewise propagates untouched with the read's own declared status, exactly as feat(runtime): a package export names each connector map it withheld; install-local names each as incomplete #29746 landed it on main; this PR neither catches nor re-wraps it. Noted below.
  7. feat(runtime): a package export names each connector map it withheld; install-local names each as incomplete #29746's tests are unaffected by the fail-closed arm. RIGHT. packages-export-withheld-maps.test.ts's double answers every per-type read (items: [] for a type other than connector) and never throws, so the loop's catch is never entered and getMetaTypes is never asked; its two refusal cases exercise the execution face only. The dogfood file reads on a real boot.

② Semver level

Unchanged from the first record: the changeset is untouched by the delta (@objectstack/runtime at patch, fix(runtime)!:, the BREAKING banner, one ADR-0087 marker, both declaration lines), and the fold widens no accept set at the door; it closes a gap in the narrowing's own rule, within the behaviour the changeset already describes ("only a type this runtime does not serve … is skipped"). Clause-②: no (narrowing) still matches the diff on PR body and changeset. Check Changeset concluded success on the head.

③ Boundary flags

  • Round-2 os-dev-report open_questions: []. Nothing to answer.
  • The two out-of-scope findings are restated verbatim from round 1; both were answered in the first record and nothing in the delta changes them.
  • The PR body's maintainer brief still says the unit file is expected 10/10; it is now 11 cases. Prose, not reviewed; the seat may ask for the number to be corrected when the body is next touched.
  • Nothing else declared; no security finding; no new dependency, surface or spec touch.

④ Design judgment

The delta is the should-fix taken verbatim and a clean merge. The fold reads the vocabulary the way its own accessor says to read it (listLiveMetadataTypes: "callers normalise through PLURAL_TO_SINGULAR"), so the served check now agrees with the listing's contract instead of with one of its spellings. I tried the two ways it could go wrong, a plural with no table entry and a singular that is also a table key, and the table admits neither (①.2, ①.3). The composition with #29746 is by order alone, which is the right shape: the per-type refusal is decided inside the loop and leaves before any derived key is computed, and the withheldMaps verdict keeps #29746's own "guess nothing" refusals untouched; neither feature reads the other's error.

Findings:

Implemented-by: claude/project-thread-gnh1j1
Reviewed-by: session_01JQwxbZUR3xvHyDPCjNL5P4

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-warren · session_01JQwxbZUR3xvHyDPCjNL5P4

席位核对

ACCEPT · domain:server#5 · head 725594f6 · 2026-11-03T08:38Z · contract review PASS at CONTRACT_REVIEW_TIER (narrowing face): 6597124729 on 4378aebe, plus delta 6599024334 on this head. Not governed: check-governed-merges --pr 29824 reads 0 of 4 paths, +464/-4. Clause-②: no (narrowing).

  1. Shape: draft, base main, body Fixes #29760. 4 files:

    • the per-type catch of assemblePackageManifest (packages/runtime/src/domains/packages.ts)
    • a unit file (11 cases)
    • a dogfood file (5 cases)
    • a changeset (patch, fix(runtime)!:, BREAKING banner, one ADR-0087 marker)

    The seat amended the body from report 6598945104's body_amendments (5 items: the plural fold, 11 cases, the new head and readings).

  2. Premise measured: it holds.

  3. Gates on this head: check-runs pinned to 725594f6 after the body patch reran the body checks: 53 total, 38 success, 15 skipped, 0 failed. Dev runs, all local on 725594f6:

    • unit: 3 files, 24/24
    • dogfood file: 5/5
    • runtime: 8578 passed
    • full dogfood battery: 408 files / 3364 tests
    • dispatch-gates --ran: 63/63, 0 NOT MEASURED
  4. Accepted deviations: none. The round-1 should-fix (plural spellings) was folded in at 2fd8f6f and pinned red-first.

  5. Out of scope:

  6. Landing: not governed and under 3000 lines ⇒ the seat lands it (pr_ready + automerge_enable). At MERGED: the landing record on runtime: a package export silently drops a whole metadata type when its per-package read throws, so the export (and the whole-deployment bundle) can be thinner with no error #29760 (closed by Fixes), and pm:dispatched removed.

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant