Skip to content

fix(metadata-protocol): a package publish judges each draft with the batch's own extension drafts folded onto their targets - #33300

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/area-A5-33152
Nov 14, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/area-A5-33152

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread
Fixes #33152

Clause-②: no — removes a false refusal the published contract already denies: the @objectstack/metadata-protocol changelog entry 0c24898 states "a package publishes as a self-consistent unit — publishPackageDrafts judges each draft against the batch's own pending declarations", and the batch's object_extension drafts were the pending declarations left out. No exported type or signature changes (dist/index.d.ts carries no pending member at all; the changed type is module-internal).
Metadata-protocol: none — no key, value or metadata type changes; os build already accepts these packages, and the batch door now gives the same verdict.

维护者速读

  • 改了什么:批量发布(publishPackageDrafts,以及草稿读取时给出的“发布预判”)收集待发布集合时,现在也收集同包的对象扩展草稿,并在判定时把这些草稿的字段并到被扩展对象上。
  • 为什么改:同一包里一个扩展草稿加的字段,被另一个草稿的公式或跨对象引用读到时,会被误判为 unknown field,整批回滚;os build 却接受。作者只能按顺序逐个发布。
  • 风险与回滚:只会让“同批扩展新增的字段”变得可解析,不会让任何原本不存在的字段通过;回滚即还原本 PR 的两个源文件改动。
  • 先看:packages/metadata-protocol/src/runtime-authoring-gate.ts(foldPendingObjectExtensions 及其在 evaluateRuntimeAuthoringGate 中的两处调用)、packages/metadata-protocol/src/protocol-publish-batch.ts(收集器多了 objectExtensions)、packages/runtime/src/domains/meta-object-extension-item.test.ts(新的 publishPackageDrafts judges a package's drafts without its own extension drafts, so a formula or hop into a field a sibling extension draft adds is refused and the whole batch rolls back #33152 用例)。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/domains/meta-object-extension-item.test.ts -t 33152,预期 3 个用例全部通过(两个“一次批量发布成功”,一个“引用不存在字段仍被拒、整批保持草稿”)。
  • 需要你做的:无,席位落地。

What changes

  • collectBatchPendingDeclarations (protocol-publish-batch.ts) collects the batch's object_extension drafts into a new objectExtensions entry of the pending set. The routing table CLOSURE_CONTEXT_KEY_BY_TYPE gains the row object_extension: 'objectExtensions'; its satisfies clause now names RuntimePendingDeclarations (the context collections plus this one key), so the drift guard and the completeness assertion over RuntimeStackContext are unchanged.
  • RuntimePendingDeclarations (module-internal) is RuntimeStackContext plus an optional objectExtensions. It is not a context collection: the live objects the gate reads are already served (the registry folds every published extension onto its target), so a pending extension is folded the same way rather than joining a collection of its own.
  • foldPendingObjectExtensions (runtime-authoring-gate.ts) adds each pending extension's fields to its target in the object universe, merged by name with a later writer replacing the whole field: the registry's mergeObjectDefinitions field merge, and the one part of it objectsAsServed folds for the build. It runs after mergePendingDeclarations, so a target that is itself a draft of the batch is folded too. On an object_extension write the written extension's own draft is left out (withoutTarget): the rules fold the written entry themselves, onto the target without the written package's extension (lint: the object_extension publish door runs no field-formula judgment, so a computed field os build refuses still publishes through PUT /meta/object_extension #33072).
  • The draft read's publish verdict (draftPublishVerdict) uses the same collector, so it gives the same answer.
  • Docblocks that described the defect (objectExtensionWriteFold in protocol-authoring-gate.ts) now describe the fix.

Tests

  • packages/runtime/src/domains/meta-object-extension-item.test.ts, new top-level describe [#33152] on the file's real rig (better-sqlite3, the real ObjectQL engine and protocol). The base gains crm_region and crm_site (a lookup region to the region); the pair publishes in one batch:
    • LIT: extension drafts { extend: 'crm_region', fields: { tier__c } } and { extend: 'crm_site', fields: { tier_label__c: formula "record.region.tier__c + '!'" } }. Expected outcome: 'published', both rows active, both fields served.
    • LIT: the same region extension draft beside an object draft crm_custom_deal whose formula hops record.region.tier__c. Expected outcome: 'published'.
    • CONTROL: the sibling adds tir__c instead. Expected outcome: 'refused', the site row's code INVALID_METADATA naming tier__c, both rows still drafts, and the region not serving tir__c.
  • packages/metadata-protocol/src/protocol-publish-drafts-closure.test.ts (the publishPackageDrafts validation closure omits same-batch dataset drafts: a package shipping dashboard+dataset together can never publish (widget-dataset-unknown) #10377 closure harness), new top-level describe: the fold keeps the target's fields and adds the draft's, does not mutate the live entry, folds onto nothing when the target is in no universe, honours withoutTarget; and evaluateRuntimeAuthoringGate refuses an object's hop into the region's tier__c (code INVALID_METADATA, status 422) without the sibling extension draft and accepts it with it.

Acceptance notes

  • Scope is the batch half of the card. The card's per-item object-door case (an object's own formula reading a field its package's published extension adds; the object save's destructive-change guard reading such a field as removed) is not in this PR: the object door judges the WRITTEN body, which replaces the served object in the snapshot, so it is a different function from the batch's universe. Reported to the seat as a follow-up.
  • The fold is additive, as mergePendingDeclarations is. For a target the package already extends in a published extension, the draft's fields are folded over the served target, which still carries the published extension. A sibling's reference to a field the draft drops therefore still resolves (it did before this PR too). The single-item extension door narrows that case (lint: the object_extension publish door runs no field-formula judgment, so a computed field os build refuses still publishes through PUT /meta/object_extension #33072); the batch does not yet. Not measured; noted.
  • Two comments in @objectstack/lint (validate-expressions.ts, the runtimeWriteType docblock; authoring-rules.ts, the expression rule's surfaces comment) and the last bullet of the unreleased .changeset/33072-object-extension-door-formula.md still say the batch refuses this case. They are outside this claim's file surface and left for the seat.
  • Measured while writing the CONTROL: a formula that hops through a lookup the same extension adds (record.region__c.tier__c, region__c declared in the written extension, tier__c existing nowhere) was published by the batch. That is a different path (a hop through an extension-added lookup); reported to the seat as a finding.

Verification

Reverse verification, read on the final source: the three metadata-protocol source files restored to BASE d4fbb4dbb (origin/main), package rebuilt, and ablation-dist-preflight confirming the marker absent from dist/. Both LIT cases red, the CONTROL green. Restore leg: git checkout HEAD --, every path's hash equal to its HEAD blob, rebuild, preflight confirms the marker in dist/.

Reverse verification (premise on origin/main code)
src marker count after mutation (expect 0): 0
object_extension row count after mutation (expect 0): 0
os-verify-lock: VERDICT command-exit 0 (metadata-protocol build)
✓ dist/: marker absent from all 24 built files
     × ⭐ LIT — an extension draft whose formula hops into a field a sibling extension draft adds publishes in one batch
     × ⭐ LIT — an object draft whose formula hops into a field a sibling extension draft adds publishes in one batch
      Tests  2 failed | 1 passed | 29 skipped (32)
"error":"object_extension/crm_site failed author-time validation: 1 issue — object 'crm_site' · objectExtensions[0] · field 'tier_label__c' expression [expression-invalid]
"error":"not published — the batch is all-or-nothing (ADR-0067 D2) and object/crm_custom_deal failed; the transaction rolled back
restored packages/metadata-protocol/src/runtime-authoring-gate.ts 38fa433d9ef8e5f8547b8e5115aa1f4d9907ff67
restored packages/metadata-protocol/src/protocol-publish-batch.ts 32a8304161547118f84110a53fc9351f80492ad9
restored packages/metadata-protocol/src/protocol-authoring-gate.ts 732e97188dd73a97b817317c3d07fe555700d44a
os-verify-lock: VERDICT command-exit 0 (restore rebuild)
preflight(present) exit=0 — hit packages/metadata-protocol/dist/index.cjs, dist/index.js

Local runs, all at head 6d0278e0d (after merging origin/main once). The incoming main commit touched only service-automation and connector files, outside this diff's packages.

Tests and typecheck (head 6d0278e)
pnpm --filter @objectstack/metadata-protocol test
 Test Files  339 passed | 9 skipped (348)
      Tests  22847 passed | 43 skipped (22890)
os-verify-lock: VERDICT command-exit 0
pnpm --filter @objectstack/metadata-protocol typecheck        EXIT=0
pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/meta-object-extension-item.test.ts
 Test Files  1 passed (1)
      Tests  32 passed (32)
pnpm --filter @objectstack/runtime typecheck                  EXIT=0
os-verify-lock: VERDICT command-exit 0
Derived gate families (dispatch-gates --commands, 69)
67 of 69 exit 0 on the first pass.
pnpm check:dual-build-cjs-loads: exit 3 (dist/ missing) on the first pass; re-run after the full build: EXIT=0,
  "123 published require entry point(s) across 72 package(s) load"
node scripts/check-plugin-teardown-shape.mjs --self-test: exit 3, PREREQUISITE (shallow clone cannot reach its
  pinned fixture) — NOT MEASURED locally; CI runs it.
dispatch-gates --ran: 69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED
node scripts/check-nul-bytes.mjs: exit 0

Not run locally, declared to CI: test:repo, the import-side packages' suites (the public surface is byte-identical, so none is owed), and the dogfood battery (no ! in the title, no narrowing arm).


Generated by Claude Code

…batch's own extension drafts folded onto their targets

collectBatchPendingDeclarations now collects the batch's object_extension
drafts, and the runtime authoring gate folds their fields onto their targets
in the object universe, so a formula or hop into a field a sibling extension
draft adds resolves as it will once the batch has published.

Claude-Session: https://claude.ai/code/session_016WV4cykhsWAtSL6Sr4VWFG
Co-authored-by: Claude <noreply@anthropic.com>
…ing extension draft's field

Claude-Session: https://claude.ai/code/session_016WV4cykhsWAtSL6Sr4VWFG
Co-authored-by: Claude <noreply@anthropic.com>
# Conflicts:
#	packages/runtime/src/domains/meta-object-extension-item.test.ts
@github-actions github-actions Bot added size/m 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 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/error-catalog.mdx (via objectExtensions (symbol, a field of type RuntimePendingDeclarations), objectExtensions (literal, a string literal in object_extension))
  • content/docs/api/metadata-api.mdx (via objectExtensions (symbol, a field of type RuntimePendingDeclarations), objectExtensions (literal, a string literal in object_extension))
  • content/docs/automation/hook-bodies.mdx (via objectExtensions (symbol, a field of type RuntimePendingDeclarations), objectExtensions (literal, a string literal in object_extension))
  • content/docs/data-modeling/object-extensions.mdx (via objectExtensions (symbol, a field of type RuntimePendingDeclarations), object_extension (symbol, a field of const object CLOSURE_CONTEXT_KEY_BY_TYPE), objectExtensions (literal, a string literal in object_extension), object_extension (literal, a string literal in objectExtensionWriteTarget))
  • content/docs/deployment/install-modes.mdx (via objectExtensions (symbol, a field of type RuntimePendingDeclarations), objectExtensions (literal, a string literal in object_extension))
  • content/docs/deployment/marketplace-handbook.mdx (via objectExtensions (symbol, a field of type RuntimePendingDeclarations), objectExtensions (literal, a string literal in object_extension))
  • content/docs/deployment/validating-metadata.mdx (via objectExtensions (symbol, a field of type RuntimePendingDeclarations), object_extension (symbol, a field of const object CLOSURE_CONTEXT_KEY_BY_TYPE), objectExtensions (literal, a string literal in object_extension), object_extension (literal, a string literal in objectExtensionWriteTarget))
  • content/docs/getting-started/quick-start.mdx (via objectExtensions (symbol, a field of type RuntimePendingDeclarations), objectExtensions (literal, a string literal in object_extension))
  • content/docs/kernel/cluster.mdx (via object_extension (symbol, a field of const object CLOSURE_CONTEXT_KEY_BY_TYPE), object_extension (literal, a string literal in objectExtensionWriteTarget))
  • content/docs/protocol/kernel/i18n-standard.mdx (via objectExtensions (symbol, a field of type RuntimePendingDeclarations), objectExtensions (literal, a string literal in object_extension))
  • content/docs/protocol/objectql/schema.mdx (via objectExtensions (symbol, a field of type RuntimePendingDeclarations), objectExtensions (literal, a string literal in object_extension))

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

  • content/docs/releases/v17/17-5.mdx (via objectExtensions (symbol, a field of type RuntimePendingDeclarations), objectExtensions (literal, a string literal in object_extension))
  • content/docs/releases/v18/18-0.mdx (via objectExtensions (symbol, a field of type RuntimePendingDeclarations), objectExtensions (literal, a string literal in object_extension))
  • content/docs/releases/v18/18-3.mdx (via objectExtensions (symbol, a field of type RuntimePendingDeclarations), object_extension (symbol, a field of const object CLOSURE_CONTEXT_KEY_BY_TYPE), objectExtensions (literal, a string literal in object_extension), object_extension (literal, a string literal in objectExtensionWriteTarget))
  • content/docs/releases/v18/18-4.mdx (via objectExtensions (symbol, a field of type RuntimePendingDeclarations), object_extension (symbol, a field of const object CLOSURE_CONTEXT_KEY_BY_TYPE), objectExtensions (literal, a string literal in object_extension), object_extension (literal, a string literal in objectExtensionWriteTarget))

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/metadata-protocol/src/protocol-authoring-gate.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 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; 123 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 — 10 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 24f2f4014d0c9bbd43ec6928942fa44b21c1dff6 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 24f2f4014d0c9bbd43ec6928942fa44b21c1dff6

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4cykhsWAtSL6Sr4VWFG

Request: review · area A5 · head 475c84b · 2026-11-14T17:00Z

Fixes #33152 · Clause-②: no · Metadata-protocol: none (open question for review: none vs added, see below). CI green on this head (35 success, 16 skipped, 0 failed); mergeable clean. Since the first head: one conflict-free-in-code merge of origin/main; the only conflict was meta-object-extension-item.test.ts, where #33205 added a describe beside this PR's, and both are kept whole (metadata-protocol 22848 passed, that runtime file 36/36 locally).

Open question from the implementer: the batch door now accepts packages it refused before (sibling extension drafts). Declared Metadata-protocol: none because the authorable set (spec, os build) is unchanged and the refusal contradicted the published 0c24898 sentence; added (minor) is the alternative.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract-review-claim: session_016WV4czuuneAppDnJbrNejw · head 475c84b · 2026-11-14T17:02Z

review#2 (server area pilot, A5).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Review: PASS

Head: 475c84b5230bc68c48a4368be0661e8cb3bbc6a8 · reviewer review#2 (session_016WV4czuuneAppDnJbrNejw) · area A5 · Fixes #33152

Checked:

  • CLOSURE_CONTEXT_KEY_BY_TYPE gains object_extension → 'objectExtensions', and collectBatchPendingDeclarations routes the batch's extension drafts there through the same loop, guard and repository read as the other types. RuntimePendingDeclarations widens to carry the key; the completeness assertion still reads RuntimeStackContext, so objectExtensions is correctly not treated as a context collection.
  • foldPendingObjectExtensions copies each target and merges the drafts' fields by name onto the served object, a later writer replacing the whole field, which matches the registry's field merge and lint's objectsAsServed. It is applied after mergePendingDeclarations, so a target that is itself a draft in the batch is extended too. An extension write's own target is left out (withoutTarget), so the lint: the object_extension publish door runs no field-formula judgment, so a computed field os build refuses still publishes through PUT /meta/object_extension #33072 fold of the written entry is not doubled. The subject-objects path folds the same way.
  • It is additive and total: malformed entries are skipped, a target in neither the batch nor the live universe gets nothing, so a hop into a field nobody adds is still refused and the batch still rolls back (pinned). The new tests cover the batch door accepting the sibling formula and lookup hop, and the runtime door end to end.
  • CI green; mergeable clean. The merge of main touched only a test file, keeping both describes.

On the declaration question: Metadata-protocol: none is right. No key, accepted value or metadata type changes; what moves is a door's verdict on packages os build already accepts. Clause-②: no is defensible because the published changelog sentence (0c24898) already says the batch is judged against its own pending declarations, so the contract text does not change. Clause-②: yes (widening) would also be correct and matches how #33249 and #33266 declared a door that newly answers where it refused. Either one is acceptable; no change is required.

Nit (pre-existing, not introduced): the fold is additive, so a batch whose extension draft drops a field the published extension added still has that field in the served object while judging siblings. It was accepted before this change as well.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4cykhsWAtSL6Sr4VWFG

ACCEPT · area A5 · head 475c84b · 2026-11-14T17:04Z

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 14, 2026 17:05
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 14, 2026
Merged via the queue into main with commit 505a239 Nov 14, 2026
52 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/area-A5-33152 branch November 14, 2026 18:06
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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

2 participants