Skip to content

fix(metadata-protocol)!: the metadata save and publish doors refuse a select field bound to a picklist no loaded package declares - #29956

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

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-y01t03

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #29898

Clause-②: no (narrowing) — the metadata save door (draft and live), the per-item and package publish, and the restore doors refuse an object or object_extension whose field names a picklist no loaded package declares, a body they accepted before. Nothing is widened.
Metadata-protocol: narrowed
Skills-docs: no move — skills/** teaches no picklist REFERENCE verdict (the two mentions are "a picklist-bound field's options are never patchable" and the stack-key list), and content/docs/** carries no severity table for this reference; the prescription ships in the refusal text, the changeset's FROM → TO and the new upgrade-guide entry.

维护者速读

  • 改了什么:对象(以及对象扩展)里 select / multiselect 字段绑定到一个"本环境没有任何包声明"的 picklist 时,元数据保存门、单项与整包发布、以及回滚/还原门现在直接 422 拒绝并点名字段与那个清单;以前存下来并发布成功,字段被服务成"零选项",之后对该字段的每次写入都被拒。
  • 为什么改:启动与后装载早就拒绝这个形状,os validate / os build 也拒绝栈内字段,只有运行时写入这道门放行,作者(或经 API 写元数据的 agent)拼错一个清单名就得到一张永远存不进数据的字段,且 _diagnostics.valid: true 什么都不提示。
  • 风险与回滚:收窄写入门。对象已持有的绑定不重判(再保存照旧成功),migrate-stored 自愈不判;此前存下的草稿会在发布时被拒(它本来就让该字段写入全部失败)。回滚即 revert 本 PR,无数据迁移。
  • 先看:packages/metadata-protocol/src/protocol-save-door-refusals.ts(新判定 unknownPicklistBindingSaveRefusal,"已持有绑定"豁免是风险 hunk)、packages/metadata-protocol/src/protocol.ts(保存门与发布晋升的接入点、方法文档)、packages/cli/src/utils/picklist-references.boot-parity.test.ts(两道门判定一致的 pin)。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/domains/meta-picklist-binding-door.test.ts,预期 5 项全过:未声明清单在对象与 .custom 扩展上各被拒一次,依赖包的清单、.custom 基包的清单、同环境另一个已安装包的清单各自保存+发布成功并被服务上选项。
  • 需要你做的:无,席位落地(本 PR 动了 packages/spec 的迁移条目,按席位安排走 CONTRACT_REVIEW_TIER 复核)。

What changes

The dangling-reference refusal family at the metadata save door gains its picklist member.

  • New judge — unknownPicklistBindingSaveRefusal in packages/metadata-protocol/src/protocol-save-door-refusals.ts, beside unknownGrantObjectSaveRefusal (metadata: a runtime permission-set save accepts a grant on an object that does not exist (200) — the build refuses the same grant (#18202), so a typo silently grants nothing #28990) and unknownDatasourceSaveRefusal (metadata: an object bound to a datasource that does not exist publishes successfully, and is then silently unregistered (data calls 404 "not registered"); Studio still lists it #26724), whose shape it follows. It judges an object and an object_extension body (an extension's fields join the object it extends, so the object it names is extend), reads which picklists the body's fields name through the boot audit's own walk (collectPicklistReferences, @objectstack/metadata/picklist-references; the os validate door walks the parsed artifact with its own reader and agrees with this door by pin, not by sharing the walk), and refuses 422 INVALID_METADATA with one finding per unknown binding at fields.FIELD.picklist, naming the field and the list.
  • What counts as declared — the engine registry's own resolvePicklistOptions, the read that SERVES a bound field's options. So the package's own list, a dependency's, the base package's of a .custom pair and any other package installed here all resolve, and the judge cannot offer a field options the write door then refuses. A registry that cannot answer the question refuses nothing (the posture unknownGrantObjectSaveRefusal takes for getObject).
  • Where it is asked — saveMetaItem after the schema gate, in draft and in publish mode (a migrate-stored re-save excepted, the platform healing its own storage); promoteDraftForPublish on the draft body, so a draft stored before this rule, or one whose list went between its save and its publish, is refused and a batch publish aborts on it; and the restore doors through refuseRestoredBodyTheSaveDoorRefuses.
  • Not judged twice — a binding the object's current definition already holds on that field is not this write's: a re-save, a stored-row migration or a restore that keeps it passes, and a list that has gone since is not the re-saving author's to answer for. There is no pending arm, because picklist is not runtime-creatable (allowRuntimeCreate: false), so no draft of a batch can declare one.
  • packages/spec — one ADR-0087 semantic upgrade-guide entry, picklist-binding-unknown-refused. ⛔ No schema shape under packages/spec/src/** is touched (the order's stop condition): FieldSchema.picklist is unchanged, and the refusal is a door judgement against the registry, not a Zod refinement — the verdict depends on which packages are installed, which a schema cannot see.
  • packages/cli — one source comment in the os validate / os build picklist judge. It said the runtime metadata write path "is not judged here" as a stated bound; it now names the door that judges it against the engine's registry.

The measured surface shift (the dispatch's route was lint; the door is metadata-protocol)

The order and the claim named "the author-time dangling-reference gate in packages/lint". Measured at 21510bba, that is not where the precedent lives, and the seat accepted the shift before this PR was opened:

So the fix joins that family, and no packages/lint file is touched (no overlap with #29926, which edits packages/lint/src/validate-expressions.ts).

Tests

Every reading below is from objectstack-ai/objectstack on this branch, each heavy command under scripts/pm/os-verify-lock.sh (slot dev-29898). The suites ran at f04c956c. Since then d1809dd5 adds only scripts/engine-double-contract.pinned.json (the ledger row for the new test's engine double, written by the gate's own --write), and 8d7a02d5 corrects two source comments and adds the changeset's Skills-docs: line; no package or dogfood test reads either.

suite reading
the new protocol pins + the restore-door family Test Files 2 passed (2), Tests 63 passed (63), lock VERDICT command-exit 0
@objectstack/metadata-protocol whole package `Test Files 312 passed
the real-SchemaRegistry door test (@objectstack/runtime) Test Files 1 passed (1), Tests 5 passed (5)
the two-judge agreement pin (@objectstack/cli, unit) Test Files 1 passed (1), Tests 4 passed (4)
@objectstack/cli unit layer `Tests 5279 passed
typecheck @objectstack/metadata-protocol and @objectstack/cli tsc --noEmit clean (lock VERDICT command-exit 0)
full dogfood battery at f04c956c (narrowing arm) `Test Files 414 passed

New and changed pins:

  • packages/metadata-protocol/src/protocol.unknown-picklist-binding-refusal.test.ts (new) — through saveMetaItem (draft into a package, draft naming none, a publish save, a publish save under force), publishMetaItem and publishPackageDrafts: the envelope (code, status), the issues[].path set and the first sentence of each finding, nothing stored and nothing promoted; a multiselect judged as a select; an object_extension judged under the object it extends; a list that goes between a draft's save and its publish; the migrate-stored exemption; and the controls — a resolved list, no binding at all, a held binding on a list gone since (with the two re-binding cases that ARE refused), and a registry that cannot resolve a list.
  • packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts — one more member of the family it exists for, at both restore doors (rollbackMetaItem, revertCommit) with their controls.
  • packages/runtime/src/domains/meta-picklist-binding-door.test.ts (new) — the real SchemaRegistry behind a real ObjectQL + better-sqlite3, a code base package, its .custom pair, a second installed list package and a runtime package: the two refusals, and the three "declared" controls each saved, published AND served with the list's options.
  • packages/cli/src/utils/picklist-references.boot-parity.test.ts — a second top-level describe in the harness that already pins this door against the boot (AGENTS.md: a new pin for a component with a shared harness file goes into that file). It answers the seat's ask directly: the two judges cannot share one judgement (this door reads the parsed artifact and weighs outside dependencies; the save door asks the live registry), so the agreement is pinned — on a list a SIBLING package of the same artifact owns, where they could disagree, and on a name nothing declares, where both refuse the same field (the paths are rooted in their own subjects, so the artifact-rooted path ends with the body-rooted one).
Ablation — each door's negative pins really fail without the fix (three legs)

Leg 1, the save door. One leg, through node scripts/ablation-replace.mjs in wrap mode: the save door's call site in saveMetaItem (the four lines that ask unknownPicklistBindingSaveRefusal and throw) replaced by a marker, anchor x1 -> x0, blob becf5c5fcc0e -> 19085994bfa4. The suite resolves ./protocol.js from source, so no build sits between the mutation and the run.

× a draft save into a package is refused 422 at `fields.<field>.picklist` …
× a draft save naming no package is refused 422 …
× a publish save is refused 422 …
× a publish save under `force` is refused 422 …
× a `multiselect` is judged as a `select` is …
× an `object_extension` field bound to an unknown list is refused the same way …
× an author re-saving the same draft is still refused
× a re-save that keeps a binding the object already holds on a list gone since …
Test Files  1 failed (1)
     Tests  8 failed | 7 passed (15)

The 7 that stay green are the publish-door pins (their own call site in promoteDraftForPublish is untouched) and the controls — the direction expected. Restore: blob after restore becf5c5fcc0e == blob at HEAD, git diff HEAD empty.

Leg 2, the publish door. The throw of the call site in promoteDraftForPublish replaced by a marker, anchor x1 -> x0, blob becf5c5fcc0e -> f15546e7b85a, same suite:

× publishMetaItem refuses it, and the draft stays a draft
× publishPackageDrafts answers `refused`, names it, and promotes nothing of the batch
× a list that goes between the draft save and its publish: the per-item publish is refused
× `migrate-stored` is not judged by this door, and the draft's publish is still refused
Test Files  1 failed (1)
     Tests  4 failed | 11 passed (15)

Exactly the three publish-door pins and the migrate-stored "publish is still refused" assertion; the save-door pins and the controls stay green. Restore: blob after restore becf5c5fcc0e == blob at HEAD, git diff HEAD empty.

Leg 3, the restore doors. The throw in refuseRestoredBodyTheSaveDoorRefuses (protocol-authoring-gate.ts) replaced by a marker, anchor x1 -> x0, blob a114a4c2f1ff -> e7d365211ff2, protocol.restore-doors-save-refusal-family.test.ts:

× an object restored with a field bound to a picklist no loaded package declares now (unknownPicklistBindingSaveRefusal) > rollbackMetaItem: answers INVALID_METADATA / 422; nothing is stored
× … > revertCommit: reports INVALID_METADATA in failed[]; nothing is stored
Test Files  1 failed (1)
     Tests  2 failed | 46 passed (48)

Exactly the two picklist refusal cases of pinBothDoors; every other family member and the controls stay green. Restore: blob after restore a114a4c2f1ff == blob at HEAD, git diff HEAD empty.

Acceptance notes

  • The boot's own refusal (describeUnresolvedPicklistReferences) is unchanged and still the authority for a package's own objects at load; this door is the authoring half it named as missing.
  • One deliberate divergence between the two judges, pre-existing on the CLI side: a list owned by a package that is installed in the same environment but is neither the stack itself, a sibling in the same artifact nor a declared dependency. The save door accepts it (the registry resolves it, and it is what the field will be served). os validate cannot see the environment, so it judges the stack alone, as it did before this PR. The new door never refuses a list the registry serves, so it cannot be stricter than the serve path; the runtime test pins that case as a control.
  • A copy is judged like any other write. duplicatePackage and the unmanaged import copy items through saveMetaItem in publish mode, and the copy target holds nothing yet, so a source object holding a binding on a list that is gone since is now refused when copied (the import rolls back). That is the same property unknownGrantObjectSaveRefusal (metadata: a runtime permission-set save accepts a grant on an object that does not exist (200) — the build refuses the same grant (#18202), so a typo silently grants nothing #28990) already gives those two paths, and it is right on the merits: the copy would be served with no options.
  • A tenant-authored body was already excluded from the boot audit on purpose (a stored row must not be able to fail a boot). That stays true: this judge refuses the WRITE, so the row never exists to be judged at boot.

Verification

  • PUT /api/v1/meta/object/x_probe?mode=draft&package=com.qa.x with fields.t = { type: 'select', picklist: 'nope_list' } → 422 INVALID_METADATA, finding at fields.t.picklist naming the field and nope_list; nothing stored, so POST /api/v1/packages/com.qa.x/publish-drafts has nothing of it to publish (the card's steps 2–5).
  • The same PUT with a picklist a package installed here declares → 200, publish-drafts → published, GET /api/v1/meta/object/x_probe serves t with that list's options, and POST /api/v1/data/x_probe with one of its values → 200.
Gate sweep — node scripts/pm/dispatch-gates.mjs --ran: 95 derived families accounted for, 94 run, 1 NOT-MEASURED

Every family dispatch-gates.mjs --commands derives for this diff was run locally and recorded; --ran answers ✓ 95 derived famil(ies) accounted for — 94 run, 1 NOT-MEASURED.

  • 94 exit 0. Five of them were green only on a second run, each for a reason outside the diff, with the fix noted: check:engine-double-contract (the new test's engine double had no ledger row; added by the gate's own --write, commit d1809dd5), check-plugin-teardown-shape --self-test (needs full history; green after unshallowing the clone), check:dts-closure (spec dist was rebuilt at the same moment by the dogfood build; green on rerun, 225/225), check:i18n-coverage and check:type-check-debt (build prerequisites and the shared lock; green on rerun, 426 baselined, none new and 0 raw tsc error(s)).
  • NOT MEASURED: check:dual-build-cjs-loads exits 3, the prerequisite refusal, because packages this diff does not touch have no local dist. CI's build lane runs it.
  • Repo-wide pnpm lint: exit 0, run locally after the PR opened.
  • Not run locally, left to CI: the @objectstack/cli integration vitest layer (no integration-tier file is touched).

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzis5HWU3u6jXfEfM17

…t at the save and publish doors

Claude-Session: https://claude.ai/code/session_01WGufzis5HWU3u6jXfEfM17
Co-authored-by: Claude <noreply@anthropic.com>
…t at the save and publish doors

Claude-Session: https://claude.ai/code/session_01WGufzis5HWU3u6jXfEfM17
Co-authored-by: Claude <noreply@anthropic.com>
…t at the save and publish doors

Claude-Session: https://claude.ai/code/session_01WGufzis5HWU3u6jXfEfM17
Co-authored-by: Claude <noreply@anthropic.com>
…t at the save and publish doors

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

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

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/error-catalog.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownGrantObjectSaveRefusal; a string literal in unknownPicklistBindingSaveRefusal; a string literal on a changed line))
  • content/docs/automation/email-templates.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownGrantObjectSaveRefusal; a string literal in unknownPicklistBindingSaveRefusal; a string literal on a changed line))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), getOverlayRepo (literal, a string literal in refuseRestoredBodyTheSaveDoorRefuses))
  • content/docs/data-modeling/currency.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownGrantObjectSaveRefusal; a string literal in unknownPicklistBindingSaveRefusal; a string literal on a changed line))
  • content/docs/data-modeling/external-datasources.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownGrantObjectSaveRefusal; a string literal in unknownPicklistBindingSaveRefusal; a string literal on a changed line))
  • content/docs/data-modeling/field-types.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownGrantObjectSaveRefusal; a string literal in unknownPicklistBindingSaveRefusal; a string literal on a changed line))
  • content/docs/deployment/install-modes.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownGrantObjectSaveRefusal; a string literal in unknownPicklistBindingSaveRefusal; a string literal on a changed line))
  • content/docs/permissions/authorization.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownGrantObjectSaveRefusal; a string literal in unknownPicklistBindingSaveRefusal; a string literal on a changed line))
  • content/docs/protocol/kernel/error-handling.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownGrantObjectSaveRefusal; a string literal in unknownPicklistBindingSaveRefusal; a string literal on a changed line))
  • content/docs/ui/forms.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownGrantObjectSaveRefusal; a string literal in unknownPicklistBindingSaveRefusal; a string literal on a changed line))

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

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownGrantObjectSaveRefusal; a string literal in unknownPicklistBindingSaveRefusal; a string literal on a changed line))
  • content/docs/releases/v17/17-5.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownGrantObjectSaveRefusal; a string literal in unknownPicklistBindingSaveRefusal; a string literal on a changed line))
  • content/docs/releases/v17/17-6.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownGrantObjectSaveRefusal; a string literal in unknownPicklistBindingSaveRefusal; a string literal on a changed line))
  • content/docs/releases/v17/17-7.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownGrantObjectSaveRefusal; a string literal in unknownPicklistBindingSaveRefusal; a string literal on a changed line))
  • content/docs/releases/v18/18-0.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownGrantObjectSaveRefusal; a string literal in unknownPicklistBindingSaveRefusal; a string literal on a changed line))
  • content/docs/releases/v18/18-1.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownGrantObjectSaveRefusal; a string literal in unknownPicklistBindingSaveRefusal; a string literal on a changed line))
  • content/docs/releases/v18/18-2.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownGrantObjectSaveRefusal; a string literal in unknownPicklistBindingSaveRefusal; a string literal on a changed line))

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/cli/src/utils/picklist-references.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 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 179 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 40fa433539cba010343b02ede4e66cccfab47d15 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 40fa433539cba010343b02ede4e66cccfab47d15

⚠️ 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 40fa433539cba010343b02ede4e66cccfab47d15 → 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#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d1809dd5f313da03290528b631c0cab33b9c9baa
Local-runs: none
Scope: .changeset/29898-picklist-binding-unknown-refused.md · packages/spec/src/migrations/entries/semantic/18.picklist-binding-unknown-refused.ts (against siblings 17.datasource-config-url-userinfo-refused.ts, entries/README.md, migrations/types.ts) · packages/metadata-protocol/src/protocol-save-door-refusals.ts (new judge, and the precedents unknownGrantObjectSaveRefusal #28990 / unknownDatasourceSaveRefusal #26724) · packages/metadata-protocol/src/protocol.ts (saveMetaItem call site ~L14711, promoteDraftForPublish ~L16700, the private method + docblock, the restore docblock, writeSource derivation L13531, the three internal saveMetaItem callers: migrate-stored L15828, duplicatePackage L19188, unmanaged import L19407) · packages/metadata-protocol/src/protocol-authoring-gate.ts (restoredBodyWriter, refuseRestoredBodyTheSaveDoorRefuses) · packages/metadata-protocol/src/protocol-meta-item-reads.ts (what readMetaItem answers for an object) · packages/metadata-protocol/src/protocol-object-extension-item.ts · packages/metadata/src/picklist-references.ts (collectPicklistReferences) · packages/objectql/src/picklist-resolution.ts · packages/objectql/src/registry.ts (resolvePicklistOptions, picklistContributions, findUnresolvedPicklistReferences, getItem/getObject) · packages/objectql/src/plugin.ts (boot audit sites) · packages/spec/src/kernel/metadata-plugin.zod.ts:932 (picklist type entry) · packages/cli/src/utils/picklist-references.ts · the four test files in the diff (protocol.unknown-picklist-binding-refusal.test.ts, protocol.restore-doors-save-refusal-family.test.ts, packages/runtime/src/domains/meta-picklist-binding-door.test.ts, packages/cli/src/utils/picklist-references.boot-parity.test.ts) · scripts/engine-double-contract.pinned.json hunk · scripts/check-adr-0087-registration.mjs (marker grammar) · scripts/check-changeset-fixed.mjs (header) · sibling changesets 29904-type-mismatch-fail-closed.md (#29923) and sec-hardening-g-external-object-organization-scope.md · AGENTS.md Post-Task Checklist · .claude/skills/pm-dispatch/references/contract-review.md · card #29898 + comments 6601945270, 6608538627 · PR #29956 body, files, check-runs.

Face: narrowing (Clause-②: no (narrowing) on PR body and changeset) + published spec path. Run in-seat as an isolated reviewer at CONTRACT_REVIEW_TIER, fed the card, the PR and the head's check-runs only.

① Derived judgments

  • Accept-set narrows at five doors, as declared. saveMetaItem (draft and publish mode, every writeSource but migrate-stored), promoteDraftForPublish (the per-item publish and, through it, publishPackageDrafts), and both restore doors via refuseRestoredBodyTheSaveDoorRefuses. Each body of type object or object_extension whose field names a picklist the engine's registry does not resolve, and which the object does not already hold on that field, is refused 422 INVALID_METADATA. Correct: the PR body, the changeset and the D3 entry name the same five doors. Nothing is widened — no new key, value or type is accepted; the method is private, so the published .d.ts surface of @objectstack/metadata-protocol gains nothing (Clause-②: no holds).
  • "Declared" is the serve path's own predicate. resolvePicklistOptions(name) !== undefined is exactly what resolvePicklistFields uses to serve a bound field's options and what the boot audit findUnresolvedPicklistReferences uses (registry.ts L8473). picklistContributions reads getItem('picklist', name) with the registry's bare/prefer-local/first-composite fallback, so the list is resolved by name across every package installed here: the package's own, a dependency's, a .custom pair's base, and an unrelated installed package all resolve. The door therefore cannot refuse a list the field would be served. Correct, and pinned over the real SchemaRegistry + sqlite in meta-picklist-binding-door.test.ts (dependency, .custom base, same-env non-dependency each saved, published and served its options).
  • Boot ordering is not reachable by this judge. Code packages register through registerApp/registerPlugin, never through saveMetaItem; protocol-package-install.ts calls no saveMetaItem; the boot audit stays the authority for a package's own objects. The save door is reached only by runtime writes after the boot sealed (REST/Studio, migrate-stored [exempt], duplicatePackage, unmanaged import, restores). A registry that lacks resolvePicklistOptions refuses nothing (the getObject posture of metadata: a runtime permission-set save accepts a grant on an object that does not exist (200) — the build refuses the same grant (#18202), so a typo silently grants nothing #28990). Correct.
  • No pending arm is right. picklist is allowRuntimeCreate: false, supportsOverlay: false (metadata-plugin.zod.ts:932): no draft of any batch can declare one, so "the same package's draft picklist saved later" cannot exist, and the ordering trap the datasource/grant judges needed a pending arm for does not arise. A draft bound to a list no installed package declares is refused at the draft save — the card's step 2 expectation, and the only way to make the list resolvable is to install a code package, after which the same save passes.
  • Held-binding exemption. Keyed on the [field, picklist] pair read from the object's current definition (readMetaItem({type:'object'}): stored active row, else artifact, else registry.getObject, which folds extend contributors). A renamed field, a new field, and a re-bind of a held field to another unknown list are each a new pair and are refused — pinned (qa_old cases). A draft is not "held" (a planted pre-rule draft re-saved is refused — pinned). migrate-stored is exempt by writeSource and would be held anyway for active rows. Bypass-free as far as I can derive. One edge not pinned: an object_extension re-save on a STORED (runtime-package) base object — step 1 of readMetaItem answers the base's stored row, which does not carry the extension's fields, so an extension holding a binding on a list gone since could be refused on re-save. For the designed case (a .custom extension on a code base) the registry fold covers it. Low reach; observation, not a blocker.
  • Side effect the body does not state: duplicatePackage (L19188) and the unmanaged import (L19407) copy items through saveMetaItem(mode: 'publish') with no exemption, and the copy target holds nothing yet, so a source object holding a binding on a list gone since is now refused when copied (the import rolls back). This is the same property unknownGrantObjectSaveRefusal already gives those two paths, and the refusal is correct on the merits (the copy would be served with no options). Should be named in the PR body's Acceptance notes; not a defect.
  • Message: Field '<object>.<field>' is bound to picklist '<list>', which no loaded package declares, so the field would be served with no options … + three remedies (correct the name / declare the list in a code package / inline options), at path: fields.<field>.picklist, code: 'custom', in the schema-gate envelope <type>/<name> failed spec validation: …. For an extension the object named is the one extended. Names the field, the list and the fix. Correct; pinned by first sentence and path at every door.
  • One inaccurate derived claim. The PR body ("the one shared walk … the boot audit's and the os validate door's"), the new CLI source comment ("over the same walk") and the dev report say the CLI judge shares collectPicklistReferences. It does not: packages/cli/src/utils/picklist-references.ts walks the parsed artifact with its own referencesOf; only its residual half reads @objectstack/metadata. The save door and the BOOT AUDIT share the walk (true); the CLI agrees by pin, not by inheritance — which the parity test's own header states correctly. A prose/comment error, no behaviour rides on it.
  • packages/spec: the D3 entry is the only change; FieldSchema.picklist and every Zod shape are untouched; the refusal is a door judgement against the registry (environment-dependent), which a schema cannot express. The order's stop condition is respected.

② Semver level

Metadata-protocol: narrowed ⇒ minor on every package the diff moves. The changeset bumps @objectstack/metadata-protocol, @objectstack/spec, @objectstack/cli minor — the three publishable packages the diff touches (packages/runtime and scripts/ carry test/ledger files only, which publish nothing; the fixed group versions them together regardless). cli moves by one source comment, so minor there is over-declared but is the letter of the rule and harmless. Summary line fix(metadata-protocol)!:, both declaration lines line-initial, exactly one ADR-0087 marker <!-- adr-0087: registered picklist-binding-unknown-refused --> whose id matches the D3 entry's id and filename 18.<id>.ts (spec is 18.2.0; README naming rule met; no index to edit; registry.ts is generated and gitignored, so nothing else to commit). **BREAKING** banner, Before/Now/Unchanged, FROM → TO with the one-line fix — the same shape as the #29923 precedent (29904-type-mismatch-fail-closed.md). Check Changeset, Lint · PM dispatch-gates, Governed Surface Queue Guard are green on this head. Consistent.

③ Boundary flags

④ Narrowing face

  • protocol-save-door-refusals.ts / protocol.ts: the judge follows unknownGrantObjectSaveRefusal's shape (registry first, then the held definition), is asked at the same position in saveMetaItem as its siblings (after the schema gate, before the authoring gate and every write), under the same migrate-stored exemption, at promoteDraftForPublish on the draft body before repo.promoteDraft (so a batch aborts), and in the restore chain. The envelope and issues[] shape are the schema gate's. Holds against the card's acceptance scenario (steps 2–5: the PUT is refused, nothing is stored, nothing publishes) and against the three "declared" controls the ruling section required (dependency, .custom base, same-env installed), each pinned over a real registry.
  • 18.picklist-binding-unknown-refused.ts: id/surface/replacement/reason/acceptanceCriteria populated in the siblings' register; surface names the doors and the field key, replacement the three remedies, reason why there is no mechanical rewrite, acceptanceCriteria a verifiable loop (save → publish → GET serves options → data write 200). No conversionIds (none applies). The entry's first consumer is the upgrade guide; no executor needed.
  • Changeset FROM → TO reads as the prescription an upgrading agent greps: picklist: 'nope_list' → a declared list, or declare it in a code package, or inline options. Matches the refusal text and the D3 replacement.
  • Pins cover: four save modes (draft+package, draft, publish, force), multiselect, object_extension, planted pre-rule draft at publishMetaItem and publishPackageDrafts (batch refused, nothing promoted), a list gone between save and publish, migrate-stored exempt then publish refused, re-save of a bad draft refused, held binding kept while new/renamed/re-bound fields refused, registry without resolvePicklistOptions, both restore doors with controls, the two-judge parity (sibling-owned list accepted by both; typo refused by both at the same field tail). Adequate for the narrowing face.

Implemented-by: claude/project-thread-y01t03
Reviewed-by: session_01WGufzibcN9oHa8kDbTTnL5

VERDICT: PASS

…klist judges' comments (#29898)

The os validate picklist judge walks the parsed artifact with its own
reader; only the boot audit shares collectPicklistReferences with the
save-door judge. Both comments now say so, and the changeset carries
the Skills-docs declaration line.

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

objectstack-fleet Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5

席位核对

Verdict: ACCEPT at head 8d7a02d5a906dd2c108f015c92d9cab68cc25c14 · 2026-11-03T17:51Z

  • Shape: 11 files: the metadata-protocol save-door judge unknownPicklistBindingSaveRefusal at save, publish-promote and restore; protocol, restore-family and real-registry runtime pins; a cli parity pin and a comment; one D3 semantic entry under packages/spec/src/migrations with no schema shape change; one minor changeset. Narrowing face + published spec path ⇒ CONTRACT_REVIEW_TIER. Review PASS 6611299038 at d1809dd5; delta PASS 6611504098 at this head (comments and a changeset line only).
  • Premise measured: the dev reproduced it on main 21510bba: a select bound to an undeclared picklist saved and published. The landing site moved from lint to the metadata-protocol save-door family (metadata: a runtime permission-set save accepts a grant on an object that does not exist (200) — the build refuses the same grant (#18202), so a typo silently grants nothing #28990 / metadata: an object bound to a datasource that does not exist publishes successfully, and is then silently unregistered (data calls 404 "not registered"); Studio still lists it #26724) by measurement, which the seat accepted at 16:00Z.
  • Gates: protocol pins 63/63, metadata-protocol 22339, runtime door 5/5, cli parity 4/4 and cli unit 5279 pass; tsc and pnpm lint exit 0; dogfood VERDICT 0. dispatch-gates: 94/95 exit 0, with dual-build-cjs-loads NOT-MEASURED (missing dists; CI runs it). Ablations red at all three arms (save 8 red, promote 4 red, restore 2 red), each restored. Landing waits for every check on this head to be green or an expected skip.
  • Accepted deviations: harness-pinned branch claude/project-thread-y01t03. The surface shift from lint to metadata-protocol was accepted. The pre-existing CLI vs save-door divergence for a same-env non-dependency list is recorded in the PR and the cli header; no card. The body was rewritten by the seat through the relay with the dev's v2 text, plus the signed trailer.
  • Landing: on all-green at this head, the seat sends pr_ready + automerge_enable through the relay.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8d7a02d5a906dd2c108f015c92d9cab68cc25c14
Local-runs: none
Scope: delta d1809dd5..8d7a02d5 (one commit, docs(metadata-protocol,cli): correct the shared-walk claim in the picklist judges' comments), 3 files: .changeset/29898-picklist-binding-unknown-refused.md (+1 line), packages/cli/src/utils/picklist-references.ts (1 comment line), packages/metadata-protocol/src/protocol-save-door-refusals.ts (import comment, +3/−1); the corrected PR body (/mnt/project-files/issue-29898/pr-body-v2.md, now live); the new head's check-runs. The full-tree scope of the d1809dd5 record (6611299038) carries over.

Face: narrowing (Clause-②: no (narrowing) on PR body and changeset) + published spec path. Run in-seat as an isolated reviewer at CONTRACT_REVIEW_TIER, fed the card, the PR and the head's check-runs only. Delta review of the d1809dd5 record (same reviewer): ①–④ judge the delta and close that record's follow-ups; everything not restated carries over unchanged.

① Derived judgments

  • No code path moves. The delta is two source comments and one changeset line (5 insertions, 2 deletions); no statement, import, test or schema changes. The accept set, the five doors, the held-binding and migrate-stored exemptions and the published .d.ts surface are exactly those judged at d1809dd5.
  • Follow-up 2 closed (the "same walk" claim). The CLI header says the save and publish doors judge the runtime write path "against the engine's registry"; "over the same walk" is gone. The protocol-save-door-refusals.ts import comment attributes the shared walk to the boot audit alone, and says os validate agrees by pin. Both are now true; the PR body's New-judge and cli bullets carry the same correction.
  • Follow-up 1 closed (the two un-ablated legs), on the dev's reported readings. The promoteDraftForPublish site went 4 failed / 11 passed (the three publish-door pins plus the migrate-stored publish case). The refuseRestoredBodyTheSaveDoorRefuses line went 2 failed / 46 passed (exactly the picklist pinBothDoors cases). Each was restored to HEAD's blob. All three arms are now measured as load-bearing.
  • Follow-up 3 closed. The body names the duplicatePackage / unmanaged-import consequence (same property as metadata: a runtime permission-set save accepts a grant on an object that does not exist (200) — the build refuses the same grant (#18202), so a typo silently grants nothing #28990) and records repo-wide pnpm lint exit 0.
  • Follow-up 4 closed. The changeset carries a line-initial Skills-docs: no move — ….
  • packages/spec: untouched by the delta; the D3 entry remains the only spec change.

② Semver level

Unchanged: minor on @objectstack/metadata-protocol, @objectstack/spec and @objectstack/cli; fix(metadata-protocol)!:; exactly one marker <!-- adr-0087: registered picklist-binding-unknown-refused -->; the BREAKING banner, Before/Now/Unchanged and FROM → TO are intact. Carries over.

③ Boundary flags

  • Head confirmed 8d7a02d5 via the API; still a draft. Zero failures at review time; the seat's all-green landing check on this head is still owed.
  • The CLI vs save-door divergence stays as ruled at d1809dd5: acceptable, recorded in the PR and the CLI header, no card.
  • The non-blocking object_extension re-save observation from d1809dd5 is unchanged.

④ Narrowing face

  • The judge, its three call sites, the envelope, the message and every pin are byte-identical to d1809dd5 outside comment text; the d1809dd5 ④ findings carry over in full. The delta adds evidence, not behaviour: all three arms are now measured red without the fix, and the comments describe the judge's inputs correctly.

Implemented-by: claude/project-thread-y01t03
Reviewed-by: session_01WGufzibcN9oHa8kDbTTnL5

VERDICT: PASS

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

Projects

None yet

1 participant