Skip to content

feat(metadata-protocol,objectql): an object extension is an authorable item of a writable package, saved, published and exported through the existing doors (#28574) - #28811

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

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

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #28574

Clause-②: yes (widening)
Metadata-protocol: added

维护者速读

  • 改了什么:对象扩展(object_extension,即「给别的包的对象加字段和规则」)成了可写包里的普通元数据项:通过现有的 /api/v1/meta 草稿、发布、历史回滚和删除口保存;发布后整个扩展体注册为该包的 extend 贡献者,字段出现在对象的每一种读取里,表也加上列;删除或卸载包时撤回;包导出把它带成 objectExtensions,可以再装回去。
  • 为什么改:Studio 在基础对象上「加字段」(N-23c)要写的就是这个:写进基础包配对的 .custom 包、字段带 __c 后缀、基础包不动(ADR-0146 D10)。以前这类数据只能由代码包的 manifest 带进来,运行时没有保存口。
  • 风险与回滚:只影响新类型 object_extension 的行;manifest 里的 objectExtensions 注册方式不变。顺带修正导出:一个包扩展了别人的对象时,导出不再把那个对象当成自己的对象输出(以前再安装会被所有权冲突拒绝)。回滚 = revert 本 PR。
  • 先看:① packages/metadata-protocol/src/protocol-object-extension-item.ts(保存/发布/回滚口的拒绝顺序与注册体);② packages/metadata-protocol/src/protocol.ts 里 registerStoredObjectExtension / resyncStoredObjectExtensions 和列表合并 listSlotNameOf 那几处;③ packages/runtime/src/domains/packages.ts 的导出循环(objectExtensions 一腿和 objects 只留自有对象的过滤)。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/domains/meta-object-extension-item.test.ts,预期 19 个测试全绿。
  • 需要你做的:无,席位落地。

What changes

  • The type (packages/spec, the one hunk seat 1 ruled, shape A): object_extension joins MetadataTypeSchema and DEFAULT_METADATA_TYPE_REGISTRY (allowRuntimeCreate: true, supportsVersioning: true, no overlay, domain: 'data', filePatterns: ['**/*.extension.ts'], the convention of the one code file that holds an extension, examples/app-showcase/src/data/extensions/account.extension.ts; the registry pin metadata-plugin.test.ts requires a pattern on every entry), plus the one classification row the satisfies (a record over every MetadataType) on OVERRIDE_TYPE_CLASSIFICATION compile-forces: object_extension: { disposition: 'excluded', basis: 'adr_0146_d10_extended' }. Regenerated: meta-url-data.generated.ts (gen:meta-url-spelling) and the two reference pages check:generated named (gen:docs). No other spec key, no new error code, ObjectExtensionSchema untouched. The route ledger, SDK and OpenAPI did not move. examples/app-showcase/src/coverage.ts gains the KIND_COVERAGE entry the closed kind roster requires (admitted by the same ruling).
  • The gate — packages/metadata-protocol/src/protocol-object-extension-item.ts (new), asked by the save door, the per-item publish door and the history restore (protocol-authoring-gate.ts), beside the __c judge it calls. VALIDATION_ERROR / 400 with an issue path, in this order: a write bound to no package; the registry's own defaultRowScope / membershipKeys refusals with their sentences (extendContributionRefusal, now one exported function in objectql/src/registry.ts that registerObject also calls); a row filed under a name other than its extend; an unregistered target; a target outside the written package's closure (narrowObjectsToPackageClosure, with closureDependencyRemedy's prescription); the __c judge from metadata-protocol + cli + install-local: __c extension fields only in a .custom package — refused at save/publish/restore, os validate, both install doors and duplicate #28564 (judgeExtensionFieldSuffix), unchanged. The body's shape is the doors' own schema gate (resolveOverlaySchema → ObjectExtensionSchema, strict, over the body with the ADR-0010 envelope peeled off): 422 INVALID_METADATA.
  • Registration — registerStoredObjectExtension registers the WHOLE parsed body (ObjectExtensionSchema output, _provenance: 'org') through SchemaRegistry.registerObjectExtension → registerObject(..., 'extend', priority). Called from the mutation sync (applyRegistryWriteThrough) and the stored-row hydration every boot / reload path shares (hydrateOverlayIntoRegistry). The manifest loop in engine.ts is not touched: what a manifest extension registers is unchanged.
  • Withdrawal — restoreArtifactRegistryView (the delete and uninstall heal) calls resyncStoredObjectExtensions(target): it withdraws every stored-row (_provenance: 'org') extend contributor of the target whose package has no active row left (SchemaRegistry.withdrawObjectExtension, new) and re-registers the rows that remain. Code-package contributors are never withdrawn.
  • Storage — ensureObjectStorage syncs the target's table on an object_extension publish, so a record can store the new field without a restart.
  • List reads — an object_extension body carries extend and no name (its schema refuses one), so mergePackageAwareOverlay slotted none of them and the list and per-package reads answered []. listSlotNameOf slots such a body by its target. The body is served as stored: no name is added, so what GET answers PUTs back through the strict schema.
  • Export — assemblePackageManifest (packages/runtime/src/domains/packages.ts) reads the package's object_extension items (per-package layer) into objectExtensions. Measured on base 7a48bf73: it did NOT carry them (the base-red export test). And a package's objects keep only the objects it owns: the registry lists an object under every contributing package, so an extending package exported the target as its own object, and re-install refused it with ObjectOwnershipConflictError.
  • Reference index — reference-sites.ts walks object_extension with ObjectExtensionSchema (the type is deliberately not in BUILTIN_METADATA_TYPE_SCHEMAS), which keeps the unwalkableSourceTypes pin at exactly ['external_catalog'] instead of widening it.
  • Translations (packages/platform-objects): the metadata-forms bundles are generated from the registry, so the type's label / description arrive in every locale (check-i18n-bundles --write). Authored in zh-CN (对象扩展), ja-JP (オブジェクト拡張) and es-ES (Extensión de Objeto) on arrival, as queue and assignment_rule were (pnpm i18n:extract then dropped the six provenance rows). The two translation ledgers that count the registry move by the new type: bare-type-display-echo-decisions.test.ts (31 → 32 types, 14 → 15 bare, 24 → 26 leaves) and the label positive control in object-lifecycle-panel-echo-decisions.test.ts (697 → 698), each with its one-line history entry.
  • The __c judge's door list (protocol-extension-field-suffix.ts header, prose only): names the object_extension gate and the adopt-orphans door fix(metadata-protocol)!: the adopt-orphans door asks the __c judge against the target package; the promotion's suffix block is seal-gated #28768 added (seat 1's note from that PR's contract review).
  • @objectstack/lint/runtime re-exports closureDependencyRemedy (one line), so the gate prints the closure sentence the other save doors print instead of re-spelling it.
  • .changeset/28574-object-extension-package-item.md: minor on spec, lint, objectql, metadata-protocol, runtime, platform-objects.

Tests

packages/runtime/src/domains/meta-object-extension-item.test.ts (new, 19 tests) over a real better-sqlite3 SqlDriver, a real ObjectQL engine, the real protocol and HttpDispatcher.handlePackages for the export. Fixture: code base com.acme.crm owning crm_account, its pair com.acme.crm.custom installed through installPackage, and an unrelated com.acme.other.

All readings at head ccfa0893 (after merging origin/main with #28768 in), unless named.

Reading Result
Card file at head Tests 19 passed (19) (lock VERDICT command-exit 0)
Card file on base 8afaf12d (every production file of this diff restored to the merge base, the new test kept, five packages rebuilt) Tests 18 failed, 1 passed (19). On base the type does not exist, so every save answers 'object_extension' is not a metadata type; the one green is the read-only-base control, refused on base too (400 for the unknown type). Restore: git checkout HEAD -- ., git diff HEAD empty, rebuilt.

The 19 tests: the field on the by-name, list, layered and registry reads with the pair as its only extend contributor and the row stored; a draft not live until publish; a record storing a value in the new column; the extension's validations reaching the object (the whole body is registered); a boot (loadMetaFromDb) re-registering the row; the pair's export carrying objectExtensions that parse; the base's export carrying none; that export re-installing into a fresh kernel through registerApp; delete and uninstall each removing the field from every read (each asserts it was on every read first); seven refusals, each VALIDATION_ERROR / 400 with its issue path where it has one, leaving no row and no contributor (outside the closure, unknown target, a .custom field without __c, defaultRowScope, membershipKeys, a package-less write, a name other than the target); the read-only base refused; and the excluded disposition not closing the type to the pair.

Ablations (one per door call, node scripts/ablation-replace.mjs from committed HEAD, package rebuilt and ablation-dist-preflight marker found in dist before each run)
  • The save / publish / restore gate. objectExtensionItemRefusal's type check rewritten to return undefined always (marker ABL1): blob 796dce22cb1c → 14a8016fdac0. Tests 7 failed, 12 passed (19): exactly the seven refusals. Restored: blob equals HEAD, git diff HEAD empty.
  • The 'extend' registration. registerStoredObjectExtension's registry.registerObjectExtension(...) call guarded off (marker ABL2): blob bf4d4c0f79a5 → cc5b2b90db49. Tests 7 failed, 12 passed (19): the reads, draft-then-publish, record, validations, boot, delete and uninstall tests. Restored clean.
  • The export leg. objectExtensions dropped from the export loop (marker ABL3): blob eec70a607e34 → ffbfc47a15cc. Tests 2 failed, 17 passed (19): the export test and the re-install test. Restored clean; runtime rebuilt, --absent preflight green, card file 19/19 again.
  • A first export ablation run in the same chain read 9 red because metadata-protocol's dist still carried ABL2 (the ablation tool restores source only); it was discarded and re-run alone on a rebuilt dist. The reading above is that second run.
Full suites and typecheck at head ccfa0893 (each under the verify lock, VERDICT command-exit 0)
Suite Result
@objectstack/metadata-protocol 295 files passed, 4 skipped · 22069 tests passed, 26 skipped
@objectstack/objectql (local) 518 files · 9959 tests
@objectstack/runtime (local) 509 files · 8247 tests passed, 19 skipped
@objectstack/spec 875 files · 24715 tests, 1 todo
@objectstack/lint 189 files · 7814 tests
@objectstack/platform-objects 107 files · 1497 tests
registry-extension-system-columns.test.ts + registry-object-extension-nonenumerated-prop-discard.test.ts 2 files · 16 tests
showcase test/coverage.test.ts; cli generate-file-name-registry-parity.test.ts 13 / 43 tests
tsc --noEmit spec, lint, objectql, metadata-protocol, runtime, showcase exit 0 each (pre-final-merge head; check:type-check-debt re-measured at head: 0 errors)
eslint --no-inline-config over the 11 changed TS files exit 0
Derived gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at head: 117 families. All 117 exit 0; --ran: 117 derived, 117 run, 0 NOT-MEASURED, 0 UNRUN. On the way: check:i18n red until the translation bundles were regenerated for the new registry row; check:query-options-erasure red on two as any casts in the new test, removed; check:generated / check:docs red after each merge until gen:docs; check-plugin-teardown-shape --self-test exit 3 on the shallow clone until its fixture commit was fetched. No gate was added.

Acceptance notes

Verification

Live probe on the stock showcase at head ad2bfc9c (this diff merged with origin/main once; the later commits change only tests, translations, prose and the file pattern), pnpm dev -- --fresh -p 3917 in examples/app-showcase, signed in as the seeded admin (POST /api/v1/auth/sign-in/email with Origin: http://localhost:3917, cookie jar). B=http://localhost:3917/api/v1.

POST $B/packages/com.example.showcase/ensure-customization
  201 {"packageId":"com.example.showcase.custom","namespace":"showcase_custom","created":true,"writable":true}

PUT  $B/meta/object_extension/showcase_account?package=com.example.showcase.custom&mode=draft
     {"extend":"showcase_account","fields":{"region__c":{"type":"text","label":"Region"}}}
  200 Saved object_extension 'showcase_account' (env-wide, package=com.example.showcase.custom, state=draft) [seq=1]

POST $B/meta/object_extension/showcase_account/publish?package=com.example.showcase.custom
  200 Published draft — type=object_extension, name=showcase_account [seq=2]

GET  $B/meta/object/showcase_account
  200 item.fields.region__c = {"label":"Region","type":"text",...}   (item._packageId com.example.showcase)

GET  $B/meta/object/showcase_account/layers
  200 code.fields.region__c present, effective.fields.region__c present

GET  $B/packages/com.example.showcase/customizations
  200 {"extensions":[{"object":"showcase_account","kind":"field","name":"region__c"}],"counts":{"extensions":1,...}}
      (the pair's 'extend' contributor, read off the registry)

GET  $B/meta/object_extension?package=com.example.showcase.custom
  200 {"items":[{"extend":"showcase_account","fields":{"region__c":{...}},"_packageId":"com.example.showcase.custom"}]}

GET  $B/packages/com.example.showcase.custom/export
  200 objectExtensions: [{"extend":"showcase_account","fields":{"region__c":{"type":"text","label":"Region"}}}], objects: []

POST $B/data/showcase_account {"name":"Probe Co","region__c":"EMEA"}
  201 record.region__c = "EMEA"

PUT  $B/meta/object_extension/showcase_account?package=com.example.showcase.custom&mode=draft
     {"extend":"showcase_account","fields":{"region":{"type":"text"}}}
  400 VALIDATION_ERROR, issues[0].path "fields.region": name it `region__c`, or change a base field's display properties in the extension's patch (`fieldPatches`).

DELETE $B/meta/object_extension/showcase_account?package=com.example.showcase.custom
  200 Deleted object_extension 'showcase_account' — it no longer exists. [seq=3]
GET  $B/meta/object/showcase_account          → region__c absent
GET  $B/packages/com.example.showcase.custom/export → no objectExtensions

Two-minute check: pnpm --filter @objectstack/runtime exec vitest run src/domains/meta-object-extension-item.test.ts, 19/19.


Generated by Claude Code

…e item of a writable package

`object_extension` joins the metadata type registry. A writable package
saves, publishes, restores and deletes one through the existing /meta
doors; the gate refuses an unbound write, the registry's extend refusals,
a name other than the target, an unknown or out-of-closure target and a
`.custom` field without `__c`. Every stored-row sync registers the whole
parsed body as the package's 'extend' contributor and delete/uninstall
withdraw it. The package export carries the rows as `objectExtensions`
and no longer exports an extended object as the package's own.

Co-Authored-By: Claude <noreply@anthropic.com>
…me the item gate in the __c judge's door list

Regenerates the metadata-plugin reference page the merge left stale.

Co-Authored-By: Claude <noreply@anthropic.com>
…its code files follow

Every registry entry declares at least one file pattern (metadata-plugin
registry pin); an empty list also reads as "no pattern" to the file-system
loader.

Co-Authored-By: Claude <noreply@anthropic.com>
…e object_extension registry row

Co-Authored-By: Claude <noreply@anthropic.com>
…ir in zh-CN, ja-JP and es-ES

The bare-type ledger and the label positive control count the new registry
type, as they did for queue and assignment_rule.

Co-Authored-By: Claude <noreply@anthropic.com>
…phans door

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Nov 1, 2026
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

48 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 693e1003c981789896ef7084109d2e13a7457e67.

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

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/lint/src/runtime.ts, packages/metadata-protocol/src/protocol-extension-field-suffix.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 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 173 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 693e1003c981789896ef7084109d2e13a7457e67 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 693e1003c981789896ef7084109d2e13a7457e67

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

…within one derivation

Adding object_extension made deriveReferenceSites() walk the FieldSchema
record and the validation-rule array a second time: object and
object_extension declare the same `fields` and `validations`, and the
`validations` walk alone is most of a type's cost. The derivation test's
5s budget went red in CI (5532ms).

A root property's walk now runs once per derivation for every source type
declaring the same property, and later types replay its bindings. The key
is the property's schema less its prose (string `description`/`title`,
which no step reads), its trail and the root's `$defs`. The cache lives in
one derivation, so two calls still agree; the index equals the uncached
walk's exactly, and against main it adds only object_extension's four
sites (field, fields, object, picklist).

Measured, ms per derivation, 6 runs per round, shared box:
- main:          431 423 425 462 535 473 / 407 422 396 461 377 380
- head before:   680 875 865 783 759 603 / 666 664 726 674 698 853
- head after:    447 473 408 482 379 373 / 415 401 390 420 413 381
- main, same session as "after": 416 402 377 417 369 380 / 477 571 401 424 419 402

No timeout changed and no test skipped.

Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head d05c186366d671c85a53069bbbde24dff6bad0b7 · 2026-11-01T22:53Z

Taken under references/contract-review.md (b): this head has waited a full triage round with no record, claim, hold or hand-off. Face schema, plus the Clause-②: yes widening, so the review runs at CONTRACT_REVIEW_TIER with ④.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d05c186366d671c85a53069bbbde24dff6bad0b7
Local-runs: none
Scope: packages/spec/src/kernel/customization-package.ts, packages/spec/src/kernel/metadata-plugin.zod.ts, packages/spec/src/meta-spelling/meta-url-data.generated.ts + packages/metadata-protocol/src/protocol-object-extension-item.ts, packages/metadata-protocol/src/protocol-extension-field-suffix.ts, packages/metadata-protocol/src/protocol-authoring-gate.ts, packages/metadata-protocol/src/protocol.ts (the save, publish, restore, hydration, write-through and delete-heal hunks), packages/objectql/src/registry.ts, packages/runtime/src/domains/packages.ts, packages/metadata-protocol/src/protocol-packaged-base-refusal.ts (judgeOverrideWrite and refusePackagedBaseOverride, read), packages/objectql/src/engine.ts (the registerApp extension loop, read), packages/lint/src/runtime-gate.ts (narrowObjectsToPackageClosure, read), packages/metadata/src/plugin.ts (artifactItemSlot, read)

Inputs: card #28574 and its four comments; ADR-0146 on main (D2, D3, D10); PR #28811 body, 22 files, two comments, and the net diff of head d05c186366d671c85a53069bbbde24dff6bad0b7 against its merge-base with origin/main, 0aaad18fc052326bc78378ead5f7e789f64e072d; the check-runs on the head. Author is objectstack-fleet[bot], so no login note is owed.

① Derived judgments

  1. MetadataTypeSchema gains object_extension (metadata-plugin.zod.ts). Widening of the published enum. Every total record over MetadataType is filled in the same diff: OVERRIDE_TYPE_CLASSIFICATION (spec) and KIND_COVERAGE (showcase). Correct.
  2. The registry row: allowRuntimeCreate: true, supportsVersioning: true, supportsOverlay: false, allowEnvironmentOverlay: false, executionPinned: false, loadOrder: 11, domain: 'data', filePatterns: ['**/*.extension.ts']. This is the seat's shape A plus the file pattern the registry pin requires. loadOrder 11 is unique (object 10, capability 12) and above object, as the pin asks. The row's description is why the type is a bare type in the translation ledgers (31 to 32 types, 24 to 26 leaves); the counts in the two ledger tests follow from the row. Correct.
  3. URL spelling: object_extensions and objectExtensions both fold to object_extension; REGISTRY_DECLARED_META_TYPES gains the type after object. I compared the hunk with packages/spec/scripts/build-meta-url-spelling.ts: the generator emits PLURAL_TO_SINGULAR first, then appends registry-derived spellings in registry order. object_extensions and objectExtensions land after books (the last manifest key) and before fields (the next registry-derived key), which is exactly where the hunk puts them. check:meta-url-spelling runs in the green Lint lap. Regenerated, not hand-edited.
  4. Override classification: object_extension: excluded / adr_0146_d10_extended. Read against its consumer judgeOverrideWrite: an undeclared body of a non-overridable type answers not_concerned, so the pair's save is admitted; a body carrying overrides is refused. The disposition does not close the type, and the last test pins that. Correct. See ④ for the refusal sentence.
  5. Who may author: the door's own writable-package refusal runs before the gate (a read-only base answers 4xx and leaves no row, pinned). The gate then admits any writable package whose dependency closure holds the target: the pair, or a vendor dependent. ADR-0146 D10 allows both: the pair's fields carry __c; a vendor's field on another vendor's object keeps the plain spelling. The __c judge is called unchanged and partitions the two cases. Correct.
  6. The __c field-suffix rule: for a pair, every unsuffixed fields key is refused with an issue path at the key (fields.region in the pin), and the sentence names fieldPatches; for a non-pair, a suffixed key is refused. An extension of the package's own object is not judged. Save, per-item publish, package publish (the shared promotion) and history restore all ask it. Correct.
  7. Registration: the whole parsed body (ObjectExtensionSchema output, _provenance: 'org') goes through the new SchemaRegistry.registerObjectExtension, which calls registerObject(..., 'extend', priority); the extend arm replaces the same package's earlier contribution. Boot, write-through and read-side hydration share one method. Withdrawal is the new withdrawObjectExtension, keyed on extend plus package, and only 'org'-provenance contributors whose package has no active row left are withdrawn, so a code package's manifest extension of the same object is never touched. Correct. The card named subtractExtenderContributions; read, it is the fold's indexes idempotency helper, so the dev's substitution is right.
  8. The stored-row shape gate: resolveOverlaySchema binds ObjectExtensionSchema, strict, over the body with the ADR-0010 envelope removed; the type stays out of BUILTIN_METADATA_TYPE_SCHEMAS. reference-sites.ts walks the type with the same schema, so the unwalkableSourceTypes pin stays at ['external_catalog']. No new error code; the gate uses VALIDATION_ERROR / 400, the shape gate 422 INVALID_METADATA. Correct.
  9. Export: assemblePackageManifest adds an objectExtensions leg and keeps only owned objects. Round trip: the exported entry re-parses and registerApp registers it as 'extend' (pinned in a fresh kernel). Card Wanted 5 says installPackage; PackageInstallManifestSchema is ManifestSchema.extend({ scope }) with no collection keys, so that door never took any collection. The deviation is recorded in the PR body and is right. The objects filter fixes a defect this card makes reachable: an extending package's export carried the target as its own object. Correct.
  10. @objectstack/lint/runtime re-exports closureDependencyRemedy. One line, additive. Correct.
  11. AuthoringGateHost gains a required member. It is not exported from the package index, so no published interface widens. Correct.
  12. Route ledger, SDK and OpenAPI: no generated file outside the three above enumerates the type list; the ledger names types only in one route's prose. "Did not move" holds. The two reference pages regenerate the enum and are prose faces.

② Semver level

Minor on spec, lint, objectql, metadata-protocol, runtime, platform-objects. Each is additive: an enum member, a registry row, a classification row, a re-export, two new SchemaRegistry methods and one exported function, a new authorable type behind existing doors, a new export collection, new translation leaves. The objects filter in the export changes an output, but it removes a row that re-install refused, so no consumer loses a working input. Minor is right.

Declarations: Clause-②: yes (widening) and Metadata-protocol: added stand at line start in the PR body and in .changeset/28574-object-extension-package-item.md. They agree with the diff: a published enum widened and a metadata type added.

Check-runs on the head: every run concluded success or skipped; none failed. No commit statuses. The gate verdicts are green.

③ Boundary flags

  • Dev cost 1, no JSON schema from /meta/types for the type: accepted. It follows from the seat's ruling (no BUILTIN_METADATA_TYPE_SCHEMAS entry). Recorded in the body and the changeset.
  • Dev cost 2, no _diagnostics badge on a draft: accepted, same reason.
  • open_questions[0], whether N-23c needs the type bound with the ADR-0010 envelope: answer A for this card. Binding it changes what a manifest's objectExtensions parse with, which is a spec ruling the card forbade. N-23c (Studio: add a field to a read-only base object — written as an extension in <base>.custom named xxx__c, base fields editable only within the display-property whitelist #28575) raises B if its form cannot be built from the object.
  • open_questions[1], whether the adopt-orphans door should judge object_extension rows: answer A. The type is new, so no package-less row of it exists, and the gate's first step refuses writing one (pinned).
  • Dev flag, subtractExtenderContributions is not a withdrawal: confirmed by reading registry.ts; the new verb is right.
  • Dev flag, installPackage refuses every collection key: confirmed by reading package-api.zod.ts; the round trip through registerApp is the documented install path.
  • Dev out-of-scope note, the suffix judge's header omits os build: prose, not filed. Agreed.
  • Prose drift I found: the registry row's comment says the artifact loader "already stores" a code package's extensions under this type. artifactItemSlot stores an item only when it carries name; a parsed extension never does (the alias folds name into extend). So the loader stores none. The PR body's probe also shows objects: [] where the code omits the key. Both prose; see should-fix 3.

④ Design judgment

customization-package.ts against ADR-0146 D10 and judgeOverrideWrite. D10 says objects are extended, never replaced, and the extension is the objectExtensions entry of the pair. An extension is the mechanism, so it has no override channel of its own; excluded on the D10 basis is coherent, and no new basis is minted. The consumer reads the row and admits the pair's undeclared save. Holds. One sentence misfits: a body that carries overrides on this type is refused as declared_type_not_overridable with the D6 text "create your own object_extension under a new name". An extension has no new name; its name is its target. The declared_object refusal still points at the manifest entry, not at the item door this PR opens. Both are one-sentence fixes in cited code (should-fix 1).

metadata-plugin.zod.ts against D10 and its first consumers. The row makes the type runtime-creatable and versioned, with no overlay; the doors refuse what the ADR refuses (__c outside the pair, unsuffixed inside it, a target outside the closure, an unknown target, a row named other than its target, a package-less write, defaultRowScope and membershipKeys). The one item per package per target shape is carried by the row name, and the body keeps extend with no name, so a GET round-trips through the strict schema. loadMetaFromDb reads active rows in store order, not by loadOrder, so the 11 is a declaration of intent rather than a boot ordering. That is harmless: the extend arm opens an entry when the owner has not arrived, and the later own registration finds no conflicting owner. Holds.

meta-url-data.generated.ts against its generator and canonicalMetaUrlType. Both spellings fold; the checklist gate reads the declared list. Holds.

Scenarios, each read against the code:

  • An extension whose target is outside the written package's closure: step 5 refuses with the closure sentence (pinned for com.acme.other). The closure keeps platform objects and package-less rows, which is the conservative direction every save door takes. Holds.
  • An extension that redefines a base field: for the pair, an unsuffixed key is refused and the sentence names fieldPatches (pinned). For a non-pair dependent, a plain fields key equal to the target's own field is spread over it by mergeObjectDefinitions. That is the manifest fold as it stands on main, and N-23d's target-field check covers fieldPatches, not fields. Not this PR's change; should-fix 2 names it.
  • An extension authored into a read-only base: the door's writable-package refusal answers before the gate (pinned 4xx, no row). The sealed-write refusal cannot fire on this type either: isSealedManagedItem needs an artifact of (object_extension, name), and the loader stores none. Holds.
  • A field lacking __c where the rule requires it: refused at save, publish and restore, with the issue path (pinned, probed). Holds.
  • Two packages extending one object with the same field name: the pair and a dependent cannot collide, since __c partitions them. Two dependents, or two pairs when one base depends on another, both at default priority 200: mergeObjectDefinitions spreads fields, so the later-registered contribution wins with no refusal and no report. Pre-existing on the manifest path; the runtime door now reaches it from Studio. Should-fix 2.
  • Export, then re-install: the pair's export carries the entry, the base's carries none, and registerApp in a fresh kernel registers the same field (pinned). The engine's registerApp loop copies a hand list of keys; at this head that list equals the schema's keys (fields, fieldPatches, label, pluralLabel, description, validations, indexes, with extend and priority read separately), so nothing is lost on the round trip today. A key added to ObjectExtensionSchema later must be added to that loop too. Observation, no finding.
  • Interplay with N-09: an undeclared object_extension row is not_concerned because the type is not overridable and no artifact of the type exists to collide with; a declared one is refused. Holds, with the sentence note above.
  • A short target name that getObject folds to a legacy ns__name key: the gate would admit it and registerObject would open a second entry under the short name. computeFQN is the identity today and the manifest loop behaves the same way, so the reach is legacy keys only. Should-fix 4.

Findings:

  1. Should-fix. judgeOverrideWrite's two sentences: the declared_type_not_overridable text does not fit an extension, and declared_object should name the object_extension item door beside the manifest entry. Cited code, not this diff; file a small follow-up.
  2. Should-fix. No door or registry refusal when two extenders of one object declare the same field name; the fold is last-writer-wins at equal priority. Pre-existing; file a follow-up card under the epic, since the runtime door makes it reachable from Studio.
  3. Should-fix. Prose: the registry row's comment claims the artifact loader stores extensions under this type; it does not. The PR body's probe shows objects: [] where the code omits the key. Fix at next touch.
  4. Should-fix. The gate could pin extend to the key getObject resolved, refusing a spelling that differs, so an admitted row can never land on a phantom entry. Legacy reach only.

No must-fix.

Implemented-by: claude/project-thread-i6gtvk
Reviewed-by: session_01AavokzJ5DndAwitDXvKy4U

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-11-01T23:08Z

Head d05c1863, report 6564077593 (on #28574), claim 6561457019. Clause-②: yes (widening), Metadata-protocol: added, with a packages/spec/** hunk. The contract review at CONTRACT_REVIEW_TIER (schema face, with ④) PASSED on this exact head, record 6565271420 (triage#1), with no must-fix.

item reading
Shape Draft, base main, Fixes #28574. 22 files. The spec hunk is the object_extension registry row, the enum member and its override classification (shape A, one PR as placed), plus filePatterns ['**/*.extension.ts'], which the registry pin requires (the review judged it correct), and the regenerated URL spelling. The item doors (save, publish, restore, delete) run the gate and the #28564 __c judge unchanged. Registration as 'extend', withdrawal, and the export leg. Translations for the new type.
Fix since hand-back ccfa0893 timed out in reference-sites.derivation.test.ts. d05c1863 caches root-property walks inside one deriveReferenceSites call: the cost is back to main's, the derivation stays pure, and the index is identical. No timeout change and no skip.
Should-fix Findings 1, 2 and 4 are in cited, pre-existing code, so the seat files them as follow-up cards. Finding 3, the registry row comment, rides the follow-up that touches it. None is a blocker.
Evidence CI green on head d05c1863 (filter=latest: 57 success, 8 skipped, 0 failed). The report shows the card's tests red on main (18/19) and green on head, three ablations each restored clean, the six touched packages' full suites, 117/117 derived gates, and a live showcase probe.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, with every check green or an expected skip.

Generated by Claude Code

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/xl tests tooling

Projects

None yet

1 participant