Skip to content

fix(rest): GET /meta/diagnostics refuses an unknown type, severity or package, and keys an alias by its canonical type (#27514) - #27620

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-nx5qkr
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-nx5qkr

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

Requested by Sales · project thread

Fixes #27514

Clause-②: no (narrowing — owes one contract-review-tier review before the queue)
Metadata-protocol: none
Skills-docs: follow-up #27632

维护者速读

  • 改了什么:诊断接口 GET /meta/diagnostics 遇到不认识的类型、严重级别或包,不再回 200「全部正常」,而是回 400 并说明原因;用别名(如 views)查询时统计按标准类型名(view)给出;该接口所有拒绝统一成一种错误信封。
  • 为什么改:这个接口是作者、AI 和 CI 判断元数据是否健康的依据。拼错类型或包名时它回「扫描 1 个类型,0 个问题」,调用方会误以为一切正常。
  • 风险与回滚:只收窄这一个只读接口的请求面,合法请求的响应形状和键集不变(别名请求的统计键从别名改为标准名,是本卡要求的变化)。回滚即还原本 PR。
  • 先看:packages/rest/src/meta-collection-routes.ts(诊断路由:severity 校验、类型拒绝、统一信封);packages/metadata-protocol/src/protocol-meta-diagnostics.ts(未知包拒绝、标准类型键);packages/rest/src/rest-meta-diagnostics-unknown-params.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/rest exec vitest run src/rest-meta-diagnostics-unknown-params.test.ts,预期 11 个用例全部通过。
  • 需要你做的:无,席位落地(落地前需一次 contract-review-tier 评审)。

What changes

Measured on main @ 556f3230 through the real route (a real ObjectStackProtocolImplementation over a real SchemaRegistry), every premise on the card held:

query before after
?type=zzz_unknown 200, scannedTypes: 1, stats.zzz_unknown zeros 400 INVALID_REQUEST
?severity=bogus 200, read as the error sweep 400 INVALID_REQUEST
?package=com.nope.missing 200, zeros for all 30 swept types 400 INVALID_REQUEST
?type=views 200, stats and entries keyed views 200, keyed view, byte-equal to ?type=view
?type=viewes (#8924) 400 flat { error: '…', code } 400 nested { error: { code, message } }, same message
?type=view&type=object (#6877) 400 nested VALIDATION_ERROR unchanged
  • severity (route): error or warning; an empty value keeps the default, as an empty type keeps the full sweep.
  • type (route): after the sweep, the route asks the list door's own verdict, refuseUnknownMetaListType (the static spelling contract, then the live type set from getMetaTypes), so GET /meta/:type and the diagnostics door agree on which names are types, and a plugin type the deployment carries live still answers. Asked after the sweep so the [finding] getMetaDiagnostics scannedTypes still overstates the swept set when a type is benignly skipped — no payload field lets a caller detect partial coverage #8924 near-miss refusal, which names the spellings the caller reached for, keeps answering first.
  • package (sweep): refused only when the sweep found no item bound to it AND the installed-package listing (getMetaItems of type package, the registry's records) does not carry it. An installed package with no items still answers its zeros, and rows bound to an uninstalled package are still swept. Fails open when the listing cannot be read.
  • alias key (sweep): a named type is swept and keyed under canonicalMetaUrlType(type), the fold canonicalMetaType applies at every /meta entry point. A spelling the fold does not map (viewes, fieldz) is unchanged, so the [finding] getMetaDiagnostics scannedTypes still overstates the swept set when a type is benignly skipped — no payload field lets a caller detect partial coverage #8924 refusal still sees what the caller wrote.
  • one envelope (route): every 400 INVALID_REQUEST the route throws or receives now leaves in the nested { error: { code, message } } its repeated-parameter refusal already used (message bounded by truncateClientMessage). Everything else keeps handleRouteError. Every code is an existing ledgered one (INVALID_REQUEST, VALIDATION_ERROR); no new code, nothing in packages/spec.

metadata-protocol/src/protocol.ts is untouched: the package check uses getMetaItems, which MetaDiagnosticsHost already carries.

Tests

  • New packages/rest/src/rest-meta-diagnostics-unknown-params.test.ts (11 cases, through the HTTP route): the three refusals, both envelope cases, the alias case, and six preservation cases (severity warning/error/empty/absent, an installed package, package=all, an installed package with no items, items bound to an uninstalled package, a live plugin type). On main: 5 failed, 6 passed. On this branch: 11 passed.
  • Two existing pins moved, both the alias key this card retires: protocol.diagnostics-store-outage.test.ts (stats.fields to stats.field) and rest-server-meta-read-org-scope.test.ts (stats.views to stats.view, plus stats.views absent).
  • pnpm --filter @objectstack/metadata-protocol test: 279 files passed, 21781 tests passed. pnpm --filter @objectstack/rest test: 335 files passed, 6589 tests passed. typecheck on both packages: exit 0.
  • Ablation, one leg per pin, each through scripts/ablation-replace.mjs (anchor hit 1 to 0, restore blob == HEAD, git diff HEAD empty): severity check, type verdict, envelope writer (all three in rest src, read by the test from source), and the package refusal and canonical key (in metadata-protocol, which the test reads from dist/: rebuilt, ablation-dist-preflight marker present exit 0, then rebuilt again and --absent exit 0). Each leg turned exactly its own pins red: severity 1, type 1, envelope 4, package 1, alias 1.

Acceptance notes

  • scripts/engine-double-contract.pinned.json gains the new rig's findOne double (written by check-engine-double-contract.mjs --write); the rig opens findOne with assertEngineFindOnePredicate. That ledger is outside the claimed file surface and is the gate's own bookkeeping for the new test.
  • With ?type=X&package=P, the package refusal reads the swept types only, so an uninstalled P whose orphan rows are all of other types is refused with that type filter. Without the filter the full sweep finds them.
  • The branch name is the card thread's harness-pinned claude/project-thread-nx5qkr, not claude/issue-27514-…; the claim records it.

Verification

All readings on head 59ef3dbe.

  • node scripts/pm/dispatch-gates.mjs --commands: 69 derived families, each run with its exit code captured before any pipe, all exit 0. --ran: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN.
  • pnpm lint (repo-wide, eslint . --no-inline-config): exit 0. eslint on packages/rest and packages/metadata-protocol: exit 0.
  • Dogfood battery (pnpm --filter @objectstack/dogfood test under os-verify-lock.sh, no shard, no path, closure built): the lock's VERDICT command-exit 0; Test Files 350 passed, 3 skipped (353); Tests 2817 passed, 20 skipped (2837); no failure.
  • Open PRs on the touched files: 36 open PR heads (every open PR but the release and mailbox branches) fetched and diffed against their merge bases; none touches the four source and test files. Two (harden(metadata): a permission set's name is unique across packages #27535, fix(plugin-webhooks): gate webhook redelivery on webhook-administration access #27474) also add rows to scripts/engine-double-contract.pinned.json; git merge-tree --write-tree of this head with each answers exit 0 (no conflict).

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F

…kage and keys aliases canonically

An unknown ?type=, ?severity= or ?package= used to be answered 200 with
zero issues, and ?type=views keyed its stats `views`. The route now
refuses all three with 400 INVALID_REQUEST in its one nested envelope:
severity in the route, type by the list door's own verdict after the
sweep, and package in the sweep (not installed and no item bound to it).
The sweep keys a named type by its canonical spelling.

Claude-Session: https://claude.ai/code/session_01L6bhgTiSuMwZbnqidCwkCM
Co-authored-by: Claude <noreply@anthropic.com>
…FindOnePredicate, pinned in the engine-double ledger

Claude-Session: https://claude.ai/code/session_01L6bhgTiSuMwZbnqidCwkCM
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/rest, touching 8 documentable anchor(s).

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 77e993ea29f1a96126f46a5e3748847ce5eb5dd3.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 26 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 77e993ea29f1a96126f46a5e3748847ce5eb5dd3 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 77e993ea29f1a96126f46a5e3748847ce5eb5dd3

⚠️ 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 77e993ea29f1a96126f46a5e3748847ce5eb5dd3 → 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#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

Contract-review-claim: session_01L6bhgUCizoYWQVJWjhdR6F · head 59ef3db · 2026-10-29T14:59Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 59ef3dbe1e68f9765be5d7f503107bb7b8d91b60
Local-runs: none
Scope: packages/rest/src/meta-collection-routes.ts, packages/metadata-protocol/src/protocol-meta-diagnostics.ts, packages/rest/src/rest-meta-diagnostics-unknown-params.test.ts, packages/rest/src/rest-server-meta-read-org-scope.test.ts, packages/metadata-protocol/src/protocol.diagnostics-store-outage.test.ts, scripts/engine-double-contract.pinned.json + packages/rest/src/meta-item-read-gate.ts (refuseUnknownMetaListType, metaTypeIsLive), packages/rest/src/error-response.ts (handleRouteError, resolveErrorResponse, classifiedRefusalAnswer, nestedRefusalEnvelope), packages/rest/src/query-multiplicity.ts (refuseRepeatedQueryParams), packages/rest/src/rest-server.ts (metaItemPackageBinding, the route host), packages/rest/src/sharing-routes.ts (respondSharingError), packages/metadata-protocol/src/protocol.ts (canonicalizeMetaRequestType, the package arm of getMetaItems, the getMetaDiagnostics host), packages/spec/src/meta-spelling/metadata-url-spelling.ts (canonicalMetaUrlType, unrecognisedMetaTypeRefusal), packages/types/src/data-error-classification.ts (truncateClientMessage, withoutDeclaredCodePrefix), packages/client/src/index.ts (getDiagnostics, unwrapResponse), ui/packages/data-objectstack/src/metadata-client.ts (diagnostics, parseError), ui/packages/app-shell/src/views/metadata-admin/useMetadata.ts, packages/runtime/src/domains (no diagnostics door), scripts/check-route-envelope.mjs, scripts/check-engine-double-contract.mjs, scripts/check-adr-0087-registration.mjs, docs/adr/0112-error-code-vocabulary-and-ledger.md, content/docs/deployment/validating-metadata.mdx

Read-only: the net diff origin/main...pr27620 (7 files, +325/−5, merge-base 1ed33609), card #27514 with all three comments (triage 6495152725, claim 6497909499, os-dev-report 6500171775), the PR body and file list, and the head's check-runs. Every cited file was read at the head's tree (git show pr27620:…), never from the PR body's account.

Review act: 2026-10-29T15:21Z

① Derived judgments

Accept-set and public-surface changes the diff implies, each judged against the cited code:

  1. ?severity= outside error/warning → 400 INVALID_REQUEST; empty keeps the default. Right. The sweep has exactly two severities (includeWarnings = request.severity === 'warning', nothing else is read), so any other value was silently the error sweep. Both known callers already type the parameter as the two values (SDK getDiagnostics severity?: 'error' | 'warning'; Studio MetadataDiagnosticsOptions.severity the same, and useGlobalDiagnostics defaults it to 'error'). The empty-string arm (|| 'error') mirrors the existing empty-type arm (|| undefined) two lines down. The check runs after refuseRepeatedQueryParams, so the array arm never reaches it.

  2. ?type= naming no metadata type → 400 INVALID_REQUEST, by refuseUnknownMetaListType, asked after the sweep. Right. Read in meta-item-read-gate.ts: static verdict (unrecognisedMetaTypeRefusal returns null for every key of META_URL_TO_SINGULAR and every CANONICAL_META_TYPES member, so an alias or a canonical spelling is silent), then the live set (metaTypeIsLive reads getMetaTypes, folds both sides through canonicalMetaUrlType, fails open on an unreadable listing). This is byte-for-byte the verdict GET /meta/:type asks at line 632 of the same module, so the two doors cannot disagree on which names are types, and a plugin type the deployment carries live still answers (pinned by the new zzz_plugin_kind case). Asking it after the sweep is correct: canonicalizeMetaRequestType inside getMetaItems throws the [finding] getMetaDiagnostics scannedTypes still overstates the swept set when a type is benignly skipped — no payload field lets a caller detect partial coverage #8924 near-miss refusal (status 400, code INVALID_REQUEST, message naming both accepted spellings) and the sweep rethrows any status-400 error, so ?type=viewes keeps the teaching message; asked first, the static verdict would have refused viewes with the generic sentence. The cost is one empty listing read per refused request. Note, not a finding: the protocol-level getMetaDiagnostics({ type: 'fieldz' }) still answers its benign count-0 (its own meta-unknown-type-namespace: a /meta type name that is not a plural of anything still mints a namespace — PUT /meta/fieldz/x answers 200 #8421 pin at head asserts so); the refusal lives at the door, which is the card's reach ("measured at the public diagnostics door"), and the REST route is the only transport — packages/runtime/src/domains has no diagnostics door.

  3. ?package= naming a package that is not installed and that no swept item is bound to → 400 INVALID_REQUEST. Right. packageIsInstalled reads getMetaItems({ type: 'package' }) and matches manifest.id ?? id, which is exactly the shape the package arm of getMetaItems publishes (manifestId = pkg.manifest.id, else pkg.id). The order of the two halves is the right one: rows bound to an uninstalled package are real and this sweep is how an author finds them (pinned by com.example.gone), an installed package with no items keeps its zeros (pinned by com.example.empty), package=all folds to undefined through metaItemPackageBinding and is never checked. A disabled package is still listed under type package (the isItemOfDisabledPackage filter is skipped for that type), so it is not refused — correct, it exists. Fail-open on an unreadable listing mirrors metaTypeIsLive and is the right direction for an existence claim.

  4. An alias ?type= is swept and keyed under its canonical type. Right. canonicalMetaUrlType is a pure table fold (META_URL_TO_SINGULAR[type] ?? type), so viewes and fieldz pass through unchanged and the [finding] getMetaDiagnostics scannedTypes still overstates the swept set when a type is benignly skipped — no payload field lets a caller detect partial coverage #8924 refusal still sees what the caller wrote. Keying stats and entries by the canonical name also hands the canonical type to shippingPackagesOf, artifactLockLayerAt, isArtifactBacked, servedLockState and computeMetadataDiagnostics, which previously received the raw plural — the new pin alias.body equals canonical.body is what proves the alias request now answers what the canonical one answers. Studio never sends type and reads stats with Object.entries, so it sees only the new key; the SDK passes type through and returns the body. The retired alias key is stated in the changeset.

  5. The route's existing 400 INVALID_REQUEST answers move from the flat { error: '…', code } to the nested { error: { code, message } }. The point I was asked to break hardest. Judged in scope and right, and declared:

  6. Message bytes across the two envelopes. Checked, because the hand-built writer bypasses resolveErrorResponse: that door's 4xx arm applies withoutDeclaredCodePrefix (anchored to INVALID_REQUEST: followed by a colon — none of the four producers' messages open so) and truncateClientMessage, which the new writer applies too. So for the producers that exist today the nested message is the same sentence the flat error carried, including the [invalid_request] tag refuseUnknownMetaListType prefixes (neither door strips it). No wire regression on the message.

  7. No new error code, no packages/spec change, no authorable key. Right; both codes (INVALID_REQUEST, VALIDATION_ERROR) are ledgered members.

② Semver level

  • Changeset .changeset/27514-diagnostics-unknown-params.md: @objectstack/rest: patch, @objectstack/metadata-protocol: patch — the two packages the diff moves (packages/types, packages/spec, packages/client and ui/ are read, not changed). Matches.
  • Clause-②: no (narrowing) on the PR body and the changeset body: the diff narrows the accept-set of a public REST door (three query values refused, one response key moved); the arm is the right one. This is the face that owes this record. Matches.
  • Metadata-protocol: none: query values of a read door are not something a metadata author writes; no key, accepted value, metadata type, meaning or default changes. Matches; none takes patch, and a non-protocol breaking change ships patch with its BREAKING declaration and ADR-0087 disposition, which this changeset carries: adr-0087: not-required (no-migration-prescription). The disposition is honest against the gate's own rule (read in check-adr-0087-registration.mjs): the body carries no FROM → TO prescription in arrow or table form; the remedy is the caller's query string, and no stored metadata holds a diagnostics request or response.
  • Check Changeset on the head: success.

③ Boundary flags

The os-dev-report (6500171775) declares open_questions: []. Its four out_of_scope_findings and the one dev flag in the PR body, each answered:

  1. Docs follow-up owed (content/docs/deployment/validating-metadata.mdx). Read at head: the query table states severity as error/warning, type as one metadata type, package as one package — nothing it says becomes false; it does not state the three refusals or the canonical stats key. A docs-only follow-up, outside this card's file surface and outside this record's scope; it does not block. Carrier: the seat, as the dev named.
  2. scripts/engine-double-contract.pinned.json outside the claimed surface. Answered: the row is the gate's own --write output for the new rig's findOne double, whose body calls assertEngineFindOnePredicate(object, query) — the gate's declared pinCall; the ledger's header forbids hand-editing it. The gate carrying it (Lint · CI, release & engine gates) is success on the head. Accepted as part of the test, not a widening.
  3. ?type=X&package=P reads the swept type only. Answered: honest — the scoped sweep found nothing bound to P under X and P is not installed; the refusal text names the remedy (omit package, or package=all). Without the filter the full sweep finds the orphan rows (pinned). Not a finding.
  4. Process deviations (lint run anyway; the PR header's attribution block links the card thread). No code effect; the PR body carries the seat's session footer. Noted, not escalated.
  5. Branch name claude/project-thread-nx5qkr, not claude/issue-27514-…. The claim (6497909499) records it as the identity; the PR body carries Fixes #27514; the Duplicate Fix Guard is advisory. Not escalated.

Two flags of my own, neither a blocker:

  1. This route declares no closed query-parameter set (no refuseUnknownQueryParams in the handler): ?typ=view still answers the full sweep at 200. Pre-existing, and AGENTS.md's Route & surface ownership rule 5 converts existing routes per lane, never as one sweep, so it is not this card's — but it is the same false-all-clear class the card is about, one axis over (parameter names, not values). Should-file as its own card for the server lane.
  2. Governed surfaces: none on the file list; Governed Surface Queue Guard is success on the head. The record is owed by the narrowing face alone.

④ Design judgment

Check-runs on the head, read at 2026-10-29T15:18Z: 48 runs, 34 success, 14 skipped, none failed, none in progress. The ten required contexts: Build Core success; Governed Surface Queue Guard success; Test Core and all eight shards success; the Lint · and Type Check · lanes (seven and four) all success; Test UI success; Lint UI, Type Check UI, Build & E2E UI, Live E2E UI skipped by the path filter (no ui/ path on the diff). Check Changeset, Dogfood Regression Gate and the PR-body guards success. Those conclusions are the gate verdicts; nothing was built, run or re-run here.

packages/rest/src/meta-collection-routes.ts — holds. Read against the handler as registered, the route host (rest-server.ts binds refuseUnknownMetaListType on the host, already used by the list route), handleRouteError, refuseRepeatedQueryParams and the route-envelope ratchet. The order inside the try is right: protocol resolution and the 501, the arity gate, the severity check, the sweep, the type verdict, then res.json. The catch keeps handleRouteError for everything that is not a classified 400 INVALID_REQUEST, so the #8855 503 and every fault still take the shared door. One finding:

  • should-fix — the refusal writer is hand-rolled (isDiagnosticsRefusal plus a literal body) where this package already has the shared derivation the sharing, security and approvals families use for exactly this re-dress: classifiedRefusalAnswer(error) → nestedRefusalEnvelope(...) (respondSharingError in sharing-routes.ts is the reference). Today the bytes are identical for the four producers (none declares userMessage or an unregistered code, none opens with INVALID_REQUEST:), so this is not a defect; but the literal writer will not pick up a future shared-classification rule (declaredCode, userMessage, prefix stripping) that every other nested family inherits, and it is a second opinion at the one boundary error-response.ts argues should have one. Converting it is a few lines and no behaviour change on this diff.

packages/metadata-protocol/src/protocol-meta-diagnostics.ts — holds. Read against canonicalizeMetaRequestType, the package arm of getMetaItems, canonicalMetaUrlType and the host Pick. The canonical fold is applied once at the top and reaches every per-type helper; the #8855/#8924 rethrow arms are untouched; the package refusal runs after the loop so an outage (503) or a near-miss (400) still wins first; packageIsInstalled's this-less signature takes the narrower Pick and the host already carries getMetaItems. On the read-invention rule: the catch returns the non-refusal ("cannot disprove"), not an invented empty answer, and the read is a protocol method, not a storage seam in that rule's vocabulary — the gate lanes are success on the head. protocol.ts is untouched, as the PR says and the diff shows.

packages/rest/src/rest-meta-diagnostics-unknown-params.test.ts — holds. A real ObjectStackProtocolImplementation over a real SchemaRegistry, the real registered handler, both halves pinned per refusal (status plus nested body.error.code) and per preservation (the count or scannedItems, never a bare 200). The severity refusal's "service never called" half holds by construction (it throws before getMetaDiagnostics). The findOne double opens with assertEngineFindOnePredicate, matching its ledger row. Imports resolve to declared dependencies (@objectstack/metadata-protocol, @objectstack/objectql as devDependencies, @objectstack/metadata-core as a dependency of packages/rest).

packages/rest/src/rest-server-meta-read-org-scope.test.ts, packages/metadata-protocol/src/protocol.diagnostics-store-outage.test.ts — hold. Each moved pin is the alias key the card retires (stats.views → stats.view, stats.fields → stats.field), each now also asserts the alias key is absent or the key set is exactly the canonical one; nothing else in either file moves.

scripts/engine-double-contract.pinned.json — holds. One --write row for the new test's findOne double, pinned: 1, in the file's sorted position.

The narrowing against the published spec on main: it takes no declared capability away at any consumer — the SDK and Studio types already describe the narrowed accept-set — and it makes the door's own refusal real where it answered a silent all-clear. No must-fix.

Implemented-by: claude/project-thread-nx5qkr
Reviewed-by: session_01L6bhgUCizoYWQVJWjhdR6F

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

维护者速读(席位终稿)

  • 改了什么:元数据诊断接口 GET /api/v1/meta/diagnostics 遇到写错的 type、severity 或 package 值时回 400 并点名错处,不再回一个「全部正常」的 200;别名类型的统计键改记规范名。
  • 为什么改:调用方(CI 脚本、Studio)把参数写错时得到「零问题」,会以为元数据干净而放行。
  • 风险与回滚:收窄接口:原来被静默当成默认值的错参数现在被拒;本路由已有的 400 统一成嵌套 { error: { code, message } },SDK 与 Studio 两种读法都兼容(契约复核已核)。changeset 标了 BREAKING。回滚 = 还原两个源文件。
  • 先看:packages/rest/src/meta-collection-routes.ts 的诊断路由,packages/metadata-protocol/src/protocol-meta-diagnostics.ts。
  • 两分钟验证:pnpm --filter @objectstack/rest exec vitest run src/rest-meta-diagnostics-unknown-params.test.ts,11 条全绿。
  • 需要你做的:无。非受管面,契约复核 PASS 在案,席位落地。

席位意见: ACCEPT。三类拒收都复用既有判决(列表门的未知类型判决、包绑定读),无新错误码。

席位核对

ACCEPT · head 59ef3dbe1e68f9765be5d7f503107bb7b8d91b60 · 2026-10-29T15:27Z

  • 形态: 7 files, +325/−5: two source files, three test files, one changeset, scripts/engine-double-contract.pinned.json (+5, the gate's own --write row for the new findOne double). No governed path, no packages/spec, under 3000 lines ⇒ Tier S.
  • 契约复核: Clause-②: no (narrowing) ⇒ owed at CONTRACT_REVIEW_TIER: PASS on this head, 6500761231, no must-fix.
  • 前提实测: the four coercions the claim named were measured on main through the real route; 5 of the 11 new door cases red there, 11 green on the head; five ablations each red on their own pin.
  • 门禁 check-runs: 48 on this head: 34 success, 14 skipped, 0 other (the review's reading, re-read 2026-10-29T15:27Z). mergeable_state: clean.
  • 接受的偏差: the nested-envelope writer is hand-rolled rather than going through the package's shared classifiedRefusalAnswer → nestedRefusalEnvelope re-dress (review should-fix 1): bytes are identical for all four producers today; left as an Acceptance note, not reworked. The engine-double ledger row sits outside the declared surface and is the gate's own output.
  • 范围外去向: Skills-docs: owed → filed docs: the /meta/diagnostics query table does not say an unknown type, severity or package is refused, or that stats is keyed by the canonical type (#27514 follow-up) #27632 (body line rewritten). Review should-file (no closed query-parameter set: a misspelled parameter name still answers a 200 sweep) → filed rest: GET /meta/diagnostics declares no closed query-parameter set, so a misspelled parameter name (?typ=view) answers a 200 full sweep #27633. Dev out-of-scope findings: answered in the review record.
  • 散文面: the changeset's BREAKING paragraph checked against the diff (the envelope move on the route's existing 400 answers, and the three refusals).
  • 落地条款: relay pr_ready + automerge_enable through the merge queue.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 29, 2026 15:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit 875d007 Oct 29, 2026
53 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-nx5qkr branch October 29, 2026 15:48
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