Skip to content

feat(objectql)!: by-name readers of apps, pages, dashboards, reports, books, cubes, tools, skills and agents consult (package, name) - #31847

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-samipm
Nov 9, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-samipm

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread

Fixes #31680

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no skill or doc teaches a bare read of a name two packages ship, an environment item read by a package caller, or a read of an undeclared package's item; ADR-0150 D3 already states the rule, and the ?package= qualifier the refusals point to is already documented on the meta read.
Narrowing: (1) a bare name two packages ship, read with no package, is refused naming both (first-party count 0; for agent, skill and tool only on a deployment that booted already holding the clash); (2) a package caller's name only the environment holds is refused, D3 (a) (count 0); (3) a package caller's name whose one holder is outside its declared closure is refused (count 0, same-artifact siblings 0).

维护者速读

  • 改了什么:应用、页面、仪表盘、报表、手册、分析立方体、工具、技能和智能体这九类元数据,按名字读取时统一走 R 的那一个包内解析器(ADR-0150 D3),不再由注册表"谁先注册就给谁"。
  • 为什么改:两个包发布同名页面时,不带包名的读取一直悄悄返回先注册的那个;包内调用方还能读到它没声明依赖的包或环境自有的条目。
  • 收窄的三种情况一律返回 409 RESOURCE_CONFLICT 并列出全部持有者;只有一个持有者或没有持有者时,行为与之前完全一样。仓库内四个示例应用触发次数均为 0。
  • 风险与回滚:只改读取路径(getApp、MetadataFacade、协议的不带包名读取),不改任何存储形状、schema 或 MCP 输入输出;回滚即 revert 本 PR。
  • 先看:packages/objectql/src/package-scoped-read.ts,然后 registry.ts 的 resolveItem,再看 protocol-meta-item-reads.ts 中的 packagelessScopedRead。
  • 需要你做的:无,席位做合同审查,史诗席位做红线审查后落地。

What changes

Child E8 of epic #31527 wires the by-name readers of nine types (app, page, dashboard, report, book, analytics_cube, tool, skill, agent) to R's one resolver, resolvePackageScopedName (the lean core R2 #31733 landed). This follows the epic seat's ruling (option A): both arms of getApp and MetadataFacade, and no bindable reader.

  • package-scoped-read.ts (new). PACKAGE_SCOPED_READ_TYPES lists the nine types. readPackageScopedItem turns the resolver's answer into a reader's: resolved returns the item, not-found returns undefined, and a refusal throws PackageScopedNameRefusedError. That error carries code: 'RESOURCE_CONFLICT', status/httpStatus: 409, the resolver's own sentence naming every candidate, and the refusal. It restates no part of the rule and holds no closure walk of its own.
  • SchemaRegistry.resolveItem(type, name, callerPackageId?, member?) and readsByPackage(type). A wired type is read through readPackageScopedItem; every other type keeps getItem.

The readers, and the caller package each one passes:

Reader Caller package passed
SchemaRegistry.getApp(name, currentPackageId?) currentPackageId
MetadataFacade.get(type, name, currentPackageId?) currentPackageId
MetadataFacade.getEntry(type, name) none
MetadataFacade.exists(type, name) none; a refusal answers true (the holders exist)
protocol readMetaItem with no packageId (GET /api/v1/meta/TYPE/NAME, both faces, so MCP agent_prompt too) null (a caller with no package)
  • Protocol read. The package-less arm calls registry.resolveItem(type, name, null, member) once, before any layer is read, and step 3 serves that answer in place of getItem and its plural retry. A read with ?package= keeps getItem; that address is G1's exact qualifier, not a caller. metadata-protocol still cannot import objectql, so it reaches the resolver only through the registry's resolveItem. No new import edge.
  • MCP. No code change and no input or output shape change (that is G2's). agent_prompt already reads through the protocol read (mcp: agent_prompt reads the kernel metadata service and misses package-shipped agents that /meta/agent/NAME serves (bare-name phase 3, Q5) #31514). One new pin: a 409 refusal travels as a protocol error naming both holders. It is never answered as "not found" or as the outage text.
  • Barrel exports: the four names and the type from @objectstack/objectql.

Tests

  • packages/objectql/src/package-scoped-e8-readers.test.ts (new, 71 tests). It runs the real SchemaRegistry, the real ObjectStackProtocolImplementation and the real MetadataFacade, and covers:
    • the type list;
    • the 409 mapping through resolveThrownHttpError;
    • narrowing (1) for each of the six types phase 1 does not cover (app, page, dashboard, report, book, analytics cube), at both protocol faces, facade.get/getEntry and getApp;
    • narrowings (2) and (3) for all nine types;
    • the CONTROL for all nine types: one holder, the environment's item, zero holders not-found, the ?package= read untouched, and getApp's nav contributions;
    • a non-wired type keeps getItem.
  • packages/mcp/src/mcp-server-runtime.agent-prompt-advertised.test.ts: one new test, the refusal travels.
  • Forced test edit, one: packages/qa/dogfood/test/bare-name-one-holder-ai-family.dogfood.test.ts (Bare-name phase 1: refuse a second package holding a non-object name whose consumers resolve without package context (ruling C on #30798) #30803 / objectql: two packages' tools under one name, /meta/tool/NAME silently serves the first-registered one; measure cloud's by-name tool read, then decide whether tool joins the bare-name one-holder list #31414, phase 1). Its cold boot holds an agent, skill and tool clash between two packages (a boot warns, it does not refuse), and it pinned that GET /meta/agent|tool/NAME with no package "keeps serving the first-registered package's item". That is narrowing (1) on the wire, and the full dogfood battery went red on exactly those 3 tests (3 failed / 3659 passed) on head 9e37aef. The pin now asserts 409 RESOURCE_CONFLICT naming both packages for agent, skill and tool, and that ?package= serves each package's own. The boot warning, the install refusals and the skill-prompt reading are unchanged.

Red on main (b126c13, the probe in the card notes): for every uncovered type, with two holders, /meta, facade.get and getApp with no package served alpha, the first registered. A package caller was served an outside-closure single holder and an environment-only item. Before the wiring, the new file ran 25 failed and 48 passed; the 25 failures were exactly the three narrowing arms.

Ablations. Each mutation was put in through scripts/ablation-replace.mjs and its run went red. Each was then restored, with the blob back to HEAD.

# Mutation Result
A1 readsByPackage → false (the protocol arm off) 6 failed
A2 facade.get → getItem 24 failed
A3 getApp → getItem 3 failed
A4 resolveItem → getItem 24 failed
A5 exists refusal → false 1 failed
A6 MCP readAgentByName reads RESOURCE_CONFLICT as a miss 1 failed
A7 protocol source arm disabled, metadata-protocol rebuilt 6 failed; ablation-dist-preflight marker present in dist

For A7, the restore leg rebuilt metadata-protocol, the preflight found the marker absent from dist with the tree clean, and the run went back to 71/71.

Acceptance notes

The four readings:

  • (a) The phase-1 one-holder tests stay green. These ran green on the final head with no edit: registry-bare-name-one-holder.test.ts, package-scoped-resolver.test.ts, core-boundary.ratchet.test.ts, registry-cross-package-collision.test.ts, engine-cross-package-collision.test.ts, and the full objectql suite. The one exception is the dogfood boot-clash pin named above: its "first registered is served" line is the behaviour E8 removes, so it is flipped. Its boot warning and install refusals are untouched.
  • (b) One-holder bare-name behaviour is unchanged except for the declared narrowings. The CONTROL block pins it for all nine types at every rewired reader. Agent, skill and tool are phase-1 covered: a new install of a held name is refused, so narrowing (1) reaches them only on a deployment that booted already holding the clash.
  • (c) Migration. None. This changes only what a read answers; no stored row, schema or shape changes (the adr-0087: not-required (no-migration-prescription) marker).
  • (d) Clause-②: no (narrowing), per seat 2's ruling. The changeset carries the BREAKING banner, with FROM→TO and the one-line fix.

Red lines:

  • ONE resolver: every wired reader goes through readPackageScopedItem → resolvePackageScopedName. No reader walks a closure or falls back to the first composite.
  • Ambiguity refuses, naming every candidate; zero holders stays not-found.
  • package-scoped-resolver.ts was not edited.
  • No packages/spec/**, ui/**, docs/adr/** or E6 readers were touched, and registry.ts installPackage was not touched.

By-name reads not rewired here. Each keeps getItem:

  • the layered read (getMetaItemLayered);
  • readCodeLayerForCarryForward;
  • protocol-artifact-lookup.ts;
  • the write side (protocol-save.ts, protocol-authoring-gate.ts);
  • MCP's diagnosedGet for a host without a protocol;
  • getAppNavContributions;
  • the getAllApps list.

The seat can file any of these as later cards.

ADR-0146 overrides: a pair's stored override row is hydrated into the registry's bare slot (protocol-registry-hydration.ts), so a package-less read still finds the environment's item first, and the full ADR-0146 suites are green.

New public names, which are the seat's call: PACKAGE_SCOPED_READ_TYPES, PackageScopedReadType, isPackageScopedReadType, readPackageScopedItem, PackageScopedNameRefusedError, SchemaRegistry.resolveItem and SchemaRegistry.readsByPackage.

Verification

Head d981cf7c1ce46776fafe4b725f2a3ef9c77dec9e, which merges origin/main fdfcc7a. Every run below is on that head, under os-verify-lock, and each VERDICT was command-exit 0.

  • Full pnpm build: green.
  • Tests: objectql 22610 passed (29 skipped), metadata-protocol 980 passed, mcp 11887 passed.
  • typecheck: clean for all three packages.
  • Lint: eslint is clean on the three packages' src and on the edited dogfood file.
  • Gates: dispatch-gates.mjs --commands listed 75 commands. All 75 exited 0, and --ran reconciles 75 derived, 75 run, 0 NOT-MEASURED.
  • Dogfood battery (pnpm --filter @objectstack/dogfood test): Test Files 441 passed, 3 skipped (444). Tests 3662 passed, 42 skipped (3704). 0 failed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ADHkmTH1oAwgDMigQ9pj37

…er under package addressing

Adds the nine-type list the ADR-0150 E8 readers consult (package, name) for,
and the 409 RESOURCE_CONFLICT refusal they raise, with the expected answers
of every reader: the two-holder refusal, D3 (a), the outside-closure step, and
the one-holder controls. The reader wiring waits for the resolver core to
become reachable from the lean ./core entry; until then the narrowing cases
are red by design and the controls green.

Claude-Session: https://claude.ai/code/session_01ADHkmTH1oAwgDMigQ9pj37
Co-authored-by: Claude <noreply@anthropic.com>
… (package, name)

getApp, MetadataFacade get/getEntry/exists and the protocol's package-less
by-name read now answer through the one package-scoped resolver for app,
page, dashboard, report, book, analytics_cube, tool, skill and agent. A name
the resolver refuses throws a coded 409 RESOURCE_CONFLICT naming every
candidate; one holder and zero holders answer as before.

Claude-Session: https://claude.ai/code/session_01ADHkmTH1oAwgDMigQ9pj37
Co-authored-by: Claude <noreply@anthropic.com>
…ing both holders

The AI-family boot pin asserted the first-registered package's agent and
tool were served at GET /meta/TYPE/NAME. That read now answers 409
RESOURCE_CONFLICT naming both holders; the ?package= read serves each
package's own, and the skill prompt list is unchanged.

Claude-Session: https://claude.ai/code/session_01ADHkmTH1oAwgDMigQ9pj37
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 9, 2026
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/objectql, touching 17 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

25 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 cc16008a1221cc0f676240c84a6b1a8285a38076.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 231 client-bound route-ledger rows — the other 166 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 166: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 107 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 — 27 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 cc16008a1221cc0f676240c84a6b1a8285a38076 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json cc16008a1221cc0f676240c84a6b1a8285a38076

⚠️ 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 cc16008a1221cc0f676240c84a6b1a8285a38076 → 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

epic:#31527#1 · os-steve · session_01ADHkmP7o3uDTti21wLTfPf

Epic red-line review: E8 #31680, head d981cf7 · 2026-11-09T06:56Z

Verdict: PASS.

Red line Reading
1. Never loosens Option A as ruled. getApp, MetadataFacade get / getEntry / exists, and the protocol's by-name read with no package all go through readPackageScopedItem → resolvePackageScopedName. No reader is bindable, there is no second closure walk, and package-scoped-resolver.ts is untouched. Zero holders stays not-found. A ?package= read stays on getItem, which is G1's.
2. Ambiguity reported, never guessed No first-registered fallback is left on any touched path. A refusal throws 409 RESOURCE_CONFLICT naming every holder. On main, the probe served alpha, the first registered, for every uncovered type; head refuses.
3. Independently releasable Narrowings (1) two holders with no package, (2) D3 (a), and (3) outside-closure, all counted 0 first-party, same-artifact siblings 0. Clause-②: no (narrowing), BREAKING banner with the one-line fix. CONTROL pins keep one-holder behaviour for all nine types at every rewired reader. Dogfood 3662/0, gates 75/75.

The forced dogfood edit is confirmed. bare-name-one-holder-ai-family boots a deployment that already holds an agent, skill and tool clash, which phase 1 only warns about. Its pin that a no-package GET /meta/agent|tool/NAME serves the first-registered package is exactly the sealed-clash case. #31677 Q1 Ruled A (6729831159) decided it: refuse and declare, with no sealed-set arm. So the flip to 409 naming both, with ?package= serving each package's own, is the ruling applied, and narrowing (1)'s "on a deployment that booted already holding the clash" declares it. Keeping first-registered there would have put a second rule on one path.

Non-blocking, for a later cleanup card: plugin-sharing (E4) also defines a PackageScopedNameRefusedError with the same 409 shape. One shared class, from objectql or core, would keep one envelope. Seat's call when convenient.

Seat 2 accepts and lands this after green CI. It is not a governed surface. After it lands, G2 #31692 still waits on K #31681 and P #31669.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQKZzp4DLtqiCkZZRM

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d981cf7c1ce46776fafe4b725f2a3ef9c77dec9e
Local-runs: none
Scope: packages/metadata-protocol/src/protocol-meta-item-reads.ts, packages/objectql/src/package-scoped-read.ts, packages/objectql/src/registry.ts, packages/objectql/src/metadata-facade.ts, packages/objectql/src/index.ts, packages/objectql/src/package-scoped-e8-readers.test.ts, packages/mcp/src/mcp-server-runtime.agent-prompt-advertised.test.ts, packages/qa/dogfood/test/bare-name-one-holder-ai-family.dogfood.test.ts + packages/objectql/src/package-scoped-resolver.ts, packages/core/src/package-scoped-resolution.ts, packages/objectql/src/bare-name-one-holder.ts, packages/metadata-protocol/src/host-engine.ts, packages/metadata-protocol/src/protocol-meta-type-schema.ts, packages/metadata-protocol/src/protocol-package-scope.ts, packages/metadata-protocol/src/protocol-meta-layered-reads.ts, packages/metadata-protocol/src/protocol-artifact-lookup.ts, packages/mcp/src/mcp-server-runtime.ts, packages/types/src/thrown-http-error.ts, packages/plugins/plugin-sharing/src/package-scoped-declarations.ts, packages/cli/scripts/check-app-nav-i18n.mjs, docs/adr/0150-behavioural-metadata-addressed-by-package-objects-keep-the-literal-prefix.md (read on origin/main, never reviewed)

Inputs: card #31680 (body and all four comments: triage, the epic seat's unblock, the seat's claim, the dev report), PR #31847 (body, nine-file list, net diff against main, both PR comments including the epic seat's red-line review 6734191686), and the 48 check-runs on d981cf7c as read at 2026-11-09T07:08Z. ⚠️ Check-runs still pending at that read: Build Core, Test Core 1/8 to 8/8, Live E2E UI, Lint · repo & tooling gates, Lint · CI/release/engine gates, Lint · docs & code guards, Lint · scoped · slot lookup & corpus, Lint · scoped · query-options erasure, Type Check · workspace / source gates / consumer gates / debt ledger. Concluded green: Check Changeset, PR-body guards, Governed Surface Queue Guard, Check PR Size, Lint · ESLint, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Spec property liveness, Flag docs affected, Dogfood Regression Gate, Plan Test Core shards, Test UI, filter, Auto Label. No check-run has failed. This record judges the tree; the pending runs are the lane seat's landing gate and are not pre-judged here.

① Derived judgments

Every accept-set and public-surface change the diff implies, each judged against the cited code on main:

  1. SchemaRegistry.resolveItem(type, name, callerPackageId?, member?) and readsByPackage(type) — new public methods. resolveItem hands the nine PACKAGE_SCOPED_READ_TYPES to readPackageScopedItem → resolvePackageScopedName (child R, package-scoped-resolver.ts, untouched) and every other type to getItem unchanged. Right: getItem's own contract is not moved, and the resolver's TypeError for a non-addressed type cannot fire because the list is a subset of core's PACKAGE_SCOPED_TYPES (all nine present at packages/core/src/package-scoped-resolution.ts:189-).
  2. SchemaRegistry.getApp(name, currentPackageId?) — answer set narrowed. Was getItem (bare slot → caller's composite → FIRST composite). Now the resolver's answer, so a two-holder bare read and a package caller's out-of-reach read throw PackageScopedNameRefusedError instead of serving a guess. Nav contributions still applied on the resolved item. Right.
  3. MetadataFacade.get / getEntry / exists — answer set narrowed, accept set unchanged. get and getEntry go through resolveItem (canonical type via canonicalMetadataServiceType, which folds plurals through the one PLURAL_TO_SINGULAR map: apps→app, analyticsCubes→analytics_cube); exists answers true on a refusal (holders are a fact) and rethrows anything else. Right; the getEntry docblock that said "without package-scoped resolution" is rewritten to say what it now does.
  4. Protocol by-name read with no packageId (GET /api/v1/meta/TYPE/NAME, both faces) — answer set narrowed. readsByPackage(request.type) is asked after canonicalizeMetaRequestType, so a plural URL spelling (/meta/apps/home) takes the same arm and the legacy plural retry cannot bypass it. The resolver is called once, before any layer; a refusal is thrown there (the layers below never run); a resolved or not-found answer is consumed only at the registry step, so the stored-row, draft and artifact layers keep their precedence for a one-holder name. The app-by-package-id key is rewritten by resolveAppPackageIdKey to { name, packageId } BEFORE this arm, so it stays on getItem (ADR-0048 §3.1 read unchanged). A ?package= read keeps getItem (G1's exact qualifier, ADR-0150 D5 row). Right.
  5. Wire envelope. The thrown error carries status: 409, httpStatus: 409, code: 'RESOURCE_CONFLICT'; resolveThrownHttpError (packages/types/src/thrown-http-error.ts:244-258) reads status then code, and RESOURCE_CONFLICT is a registered ledger code, so the door answers 409 RESOURCE_CONFLICT with the resolver's sentence, never the 500 fallback. Right.
  6. MCP agent_prompt — no source change, behaviour narrowed through the protocol read. readAgentByName (mcp-server-runtime.ts:735-754) swallows only METADATA_UNAVAILABLE_CODE and rethrows everything else, so the 409 travels as a refusal, never as "not found" and never as the mcp-server-runtime:443 把 metadata outage 答成 'Agent "X" not found' —— fail-closed 但误述,#5840 定性后的 cli 侧跟进 #6055 outage text. The new pin asserts exactly that. Right; @objectstack/mcp is correctly absent from the changeset.
  7. No new import edge. MetadataHostEngine.registry is any (host-engine.ts:17), so metadata-protocol reaches the resolver only through the registry's resolveItem; the typeof registry?.readsByPackage === 'function' guard keeps a host double without the method on getItem. Right.
  8. Barrel exports — PACKAGE_SCOPED_READ_TYPES, isPackageScopedReadType, readPackageScopedItem, PackageScopedNameRefusedError, type PackageScopedReadType from @objectstack/objectql. The lean ./core entry exports none of R's names today and none of these; consistent (see ④ N4).
  9. Dogfood pin flipped (forced edit). GET /meta/agent|skill|tool/NAME on a deployment booted holding a two-package clash: first-registered → 409 RESOURCE_CONFLICT naming both; ?package= serves each package's own; boot warning, install refusals and the prompts/get skill projection untouched. Right: that line asserted the exact fallback red line 1 forbids, on the exact path E8 rewires.
  10. Phase-1 one-holder refusal untouched for agent/skill/tool — D9 retires it per type in the trailing B children, not here. Right (reading (a)).

Wrong: none.

② Semver level

.changeset/31680-presentation-ai-reads-by-package.md: @objectstack/objectql: patch, @objectstack/metadata-protocol: patch. Both line-initial declarations present on the changeset and the PR body: Clause-②: no (narrowing) and Metadata-protocol: none. Under AGENTS.md Post-Task step 3 the level follows the kind alone: none ⇒ patch, and "a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition" — both are present: the **BREAKING** banner with the three refused reads, three FROM → TO one-line fixes, and exactly one marker, adr-0087: not-required (no-migration-prescription), which is right because nothing stored is rewritten (only what a bare read answers changes). No author-writable key, value or type changes, so Metadata-protocol: none is right. @objectstack/mcp has no source change and @objectstack/dogfood is private: true, so neither belongs in the changeset. Check Changeset check-run: success. Clause-②: no (narrowing) matches the diff — no schema, no governed path, a published accept set narrowed.

③ Boundary flags

  • open_questions Q1 — new public names, A keep / B rename. Answered A. PACKAGE_SCOPED_READ_TYPES ("readers wired") is deliberately distinct from core's PACKAGE_SCOPED_TYPES ("addressed by package"), and readPackageScopedItem is the one seam each later E child appends to; the error class name matches the one plugin-sharing (E4) already uses for the same refusal. No rename needed.
  • open_questions Q2 — the forced dogfood flip extends narrowing (1) to agent/skill/tool clashes a deployment booted holding. Answered A (refuse and declare). Red line 1 forbids a first-registered fallback on a touched path, and that pin asserted exactly that fallback on the read E8 rewires; B would put two rules on one path. The arm is declared on the PR body and the changeset ("for an agent, skill or tool only on a deployment that booted already holding the clash"). The card's own Narrowing line names only an uncovered type's two holders, so this arm goes beyond the card's declaration as filed; the epic seat's red-line review (6734191686) cites webhooks, connectors, email templates on (package, name) (ADR-0150 child E5) #31677 Q1 ruling 6729831159 ("refuse and declare, no sealed-set arm") as the maintainer's word for it. That ruling is outside this brief's inputs and was not read here: the owning seat confirms the cited ruling before landing, or takes the arm to the maintainer per the card's red-line text. Not a finding against the tree.
  • Narrowing (3) — a package caller's bare name whose one holder is outside its declared closure is refused. Declared on the PR and the changeset, not on the card's Narrowing line. It is D3 step 3 as ruled (own → unique holder in the closure → refusal), and what R's resolver on main already answers (resolvePackageScopedHolders, step 3: "⛔ Never an environment-wide guess"); getItem(type, name, pkg) served the first composite regardless of closure, so wiring the ruled rule makes the spec's own refusal real. Count 0 first-party. Within the ruled rule, not beyond it; recorded so the seat's acceptance names it.
  • Dev deviation — no assignee/reviewer set (standard order forbids GitHub MCP writes). Procedural; outside the contract.
  • Dev out-of-scope findings — residual by-name readers still on getItem (getMetaItemLayered, readCodeLayerForCarryForward, protocol-artifact-lookup, save/authoring gate, MCP diagnosedGet, getAppNavContributions, getAllApps). Confirmed in the live tree (protocol-meta-layered-reads.ts:236-239, protocol-artifact-lookup.ts:82). See ④ N3 — follow-up cards, not this PR's.

④ Design judgment

The narrowing, read against ADR-0150 on main and the cited code. Question asked of every arm: does it take a declared capability away at the consumer, or make the spec's own refusal real?

Red lines over the floor.

  • One resolver, never loosens. Every rewired reader goes readPackageScopedItem → resolvePackageScopedName → core resolvePackageScopedHolders. The diff holds no closure walk, no holder enumeration and no first-composite fallback of its own; package-scoped-resolver.ts is untouched. Zero holders → undefined (resolver not-found), never a refusal. Holds.
  • Ambiguity reported, never guessed. A refusal is the resolver's own PackageScopedRefusal (message from packageScopedRefusalMessage, naming every candidate of the refusing step) wrapped unchanged; exists answers true rather than false for it. Holds.
  • Independently releasable. One-holder and zero-holder answers unchanged at every rewired reader (CONTROL block, nine types, both faces); the phase-1 install refusal stays; no migration (nothing stored moves); Clause-②: no (narrowing) with BREAKING banner. Holds.

Scenarios tried to break it.

  • Two package holders plus an environment-authored stored row of the same name (an admin customised page/home). On main the stored-row layer served that row before getItem was reached. At head, overlaySlotBinding returns undefined for a package-less row over two composite holders (registry.ts:8410-8420), so getEnvironmentScopedItem does not count it as the environment's own item and the read is refused ambiguous-in-environment before the stored-row layer runs. This is R's own reading, accepted on main ("a package-less row that bears a package-held name is a customization, never a second item"), and it is inside narrowing (1)'s words (a bare name two packages ship, read with no package). Count 0 first-party. Not a capability the spec declares to the consumer: D3 says the environment's own item ranks first, and R defines that as a name no package holds. No finding.
  • One holder plus a stored overlay row. The resolver resolves to the holder (overlay bound), the stored-row layer still answers first at the registry step's precedence. Unchanged. No finding.
  • Plural URL spelling, app-by-package-id key, ?package= qualifier, draft/preview reads. All judged in ① items 3–4: no bypass, no new refusal on the package-id key, ?package= stays G1's (its first-composite fallback for a non-holder X is an untouched arm the ADR assigns to G1's row, not E8's), and a draft read of a two-holder bare name is the same 409 at the same door (U sends ?package=). No finding.
  • A consumer that cannot catch the throw. The only package-less getApp caller in the live tree outside tests is packages/cli/scripts/check-app-nav-i18n.mjs:909 (engine.registry.getApp(app.name) over the first-party APPS, each a one-holder name) — see N1.
  • MCP handling of the refusal. readAgentByName rethrows; buildAgentPromptResult never reaches its "not found" or outage branch for it. The pin covers both negative shapes. No finding.

Findings.

  • N1 (nit) — packages/cli/scripts/check-app-nav-i18n.mjs:909 calls engine.registry.getApp(app.name) with no package and no catch; on a deployment holding two apps of a first-party APPS name the gate would now throw the 409 rather than report a composition error. Unreachable today (each APPS entry has one holder; first-party count 0). Follow-up or accepted deviation; no effect on the contract.
  • N2 (nit) — PackageScopedNameRefusedError now exists twice: packages/plugins/plugin-sharing/src/package-scoped-declarations.ts:229 (code, status, no httpStatus) and packages/objectql/src/package-scoped-read.ts. Same name, same 409 envelope, different instanceof; no consumer catches across the two today. The epic seat has already named this as a cleanup card (one class from objectql or core). Follow-up card.
  • N3 (nit) — the residual by-name readers listed in ③ stay on getItem, so GET /meta/:type/:name/layered of a two-holder bare name still answers first-registered while the item read answers 409. Untouched behaviour, no loosening, and the changeset names exactly the reads it rewires, so the published claim is accurate. Follow-up card(s), as the dev proposes.
  • N4 (nit) — the lean @objectstack/objectql/core entry publishes SchemaRegistry (whose getApp now throws the new class) but not PackageScopedNameRefusedError; a lean consumer identifies the refusal by code/status, as the MCP pin does. Consistent with R's names, which the lean entry also does not export. Follow-up alongside N2.

No must-fix. No should-fix. The narrowing takes no declared capability away at the consumer; each refused read is one the spec on main (D3 as ruled, D3 (a), D5's 409) declares refused, and each one-holder read answers as before.

Implemented-by: claude/project-thread-samipm
Reviewed-by: fable-subagent-31847

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQKZzp4DLtqiCkZZRM

维护者速读

  • 改了什么: 应用、页面、仪表盘、报表、书、分析立方、工具、技能、智能体这九类元数据,按名字读取(不带包)时改走 ADR-0150 的同一个按包解析器:包调用方先拿自己包的,再拿声明依赖里唯一的那个;不带包的调用方先拿环境自己的,再拿唯一的包。名字被多个包占用时返回 409,并列出所有持有者。
  • 为什么改: 原来同名时按"谁先注册"返回,可能悄悄返回错的包。现在要么确定,要么明确拒绝,不再猜。
  • 风险与回滚: 属于收窄。三种原来"猜一个"的读取现在会 409:多个包同名、包读环境自己的项、包读未声明依赖的包。修复是一行(带 ?package= 或补 dependencies),changeset 写明了;仓库里四个示例应用都不需要改。回滚即还原本 PR。
  • 先看: packages/objectql/src/package-scoped-read.ts 的 readPackageScopedItem。
  • 需要你做的: 无,由席位落地。
  • 席位意见: ACCEPT。

席位核对 — ACCEPT · domain:server#2 · Head-sha d981cf7c1ce46776fafe4b725f2a3ef9c77dec9e · 2026-11-09T07:20Z

  1. Shape: draft to main, Fixes #31680, Clause-②: no (narrowing), Metadata-protocol: none. Patch changesets for @objectstack/objectql and @objectstack/metadata-protocol, with the BREAKING banner, the FROM → TO lines and the ADR-0087 marker not-required (no-migration-prescription). The PR body carries Skills-docs: none with its reason. 9 files, +567/−25.
  2. Epic: the epic seat's red-line review on this head is PASS (6734191686). It confirmed the dogfood pin flip as the sealed-clash case webhooks, connectors, email templates on (package, name) (ADR-0150 child E5) #31677 Q1 ruled A (6729831159, the maintainer's 「同意」).
  3. Contract review: CONTRACT_REVIEW_TIER, narrowing face, PASS (6734365312), with no must-fix, no should-fix and four nits. The seat takes them as follows:
  4. Forced test edit: the ai-family dogfood pin now expects 409 naming both holders for a booted agent, skill or tool clash. Accepted: it is changeset narrowing (1), as ruled.
  5. Gates:
    • Dev: 75/75 gates exit 0; objectql 22610, metadata-protocol 980 and mcp 11887 tests pass; typecheck and lint clean; dogfood 3662 passed, 0 failed; 7 ablations went red and were restored.
    • Check-runs on this head: 36 success, 15 skipped, 0 failed. Mergeable: clean.
    • Governed surfaces: 0 of 9 paths on the register.
  6. Landing: pr_ready + automerge_enable. G2 mcp: run_action and list_actions take a namespace, and skill, agent and action face names follow the ruled derivation with a duplicate refusal (ADR-0150 child G2) #31692 follows on this seat.

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/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant