Skip to content

fix(metadata-protocol)!: refuse a keyed seed whose externalId names no field of its object - #29066

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-ncjmkt
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-ncjmkt

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

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

Requested by Leehom · project thread

Fixes #28952

Clause-②: no (narrowing)
Metadata-protocol: narrowed

维护者速读

  • 改了什么:种子(seed)在 upsert / update / ignore 模式下,如果 externalId(组合键的任一成员;没写时默认是 name)不是目标对象的字段,保存、发布和恢复版本时都会被拒绝(422 INVALID_METADATA,和对象数据源写错时同一种拒绝格式,指出是哪个字段、哪个对象),什么都不写入。启动时加载种子从不拒绝,只在某个数据集的每条记录都取不到键值时打一行 warn。
  • 为什么改:原来这种种子保存时显示有效,每次发布都回报「成功、新增 1 条」,表里却每发布一次就多一份重复数据,没有任何提示,只有最终用户会发现。
  • 风险与回滚:低到中。这是对保存/发布入口的收紧:以前能存进去的这类种子现在会被拒。实测本仓库 4 个示例、平台包和 hotcrm 里没有一个种子命中。insert / replace 模式不受影响。回滚即撤销本 PR。
  • 先看:packages/metadata-protocol/src/protocol-save-door-refusals.ts(新增的 seedExternalIdSaveRefusal,判断对象字段的逻辑在这里);packages/metadata-protocol/src/protocol.ts(保存入口和发布晋升里的两处调用);packages/metadata-protocol/src/seed-loader.ts(启动加载的那行 warn)。
  • 两分钟验证:pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.seed-external-id-refusal.test.ts,预期 22/22 通过(main 上其中 12 条拒绝用例是红的,对照组是绿的)。
  • 需要你做的:无,席位落地。

What changes

The refusal. New seedExternalIdSaveRefusal in packages/metadata-protocol/src/protocol-save-door-refusals.ts, beside the other save-door refusals, with its class method in protocol.ts.

  • Judged. A seed in a keyed mode (upsert, update, ignore). Each member of externalId is checked; an undeclared externalId is SeedSchema's default, name, the same default the loader applies (dataset.externalId || 'name'). insert and replace never match by key and are not judged.
  • Against what. The object as the publish will have it: a draft of it in the same package batch (request.pending.objects, the batch closure publishPackageDrafts already collects), else the engine's registered definition (engine.registry.getObject), whose fields include extension fields. The columns the registry injects count, through the spec's resolveInjectedSystemColumns (so id and the audit and tenant columns are fields).
  • Not refused when the object cannot be resolved. On a draft save, a seed whose object is itself still a draft is left for its publish, where the batch's object draft is judged (the order's "object resolved only at publish" case). A seed whose object is neither registered nor drafted is not judged at all: the load already reports an object it cannot find.
  • Envelope. The save door's envelope for a body it judges, the one 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) answers: 422 INVALID_METADATA, message seed/NAME failed spec validation: plus specValidationFindings (the REST faces carry the headline and the prose lives in issues[]), and issues[] with one finding per missing member at externalId (externalId.N for a composite member), code: 'custom'. Each finding opens "Seed 'NAME' keys on externalId member 'FIELD', which is not a field of object 'OBJECT'" (or, undeclared, "declares no externalId, so it keys on the default 'name'"), then the consequence and the remedy, under the 500-character REST cap. No new error code.

Where it is asked. The same three places unknownDatasourceSaveRefusal is:

  • saveMetaItem, draft and publish mode, after the schema and before the authoring gate and every write. A migrate-stored re-save (the platform healing its own storage) is not judged.
  • promoteDraftForPublish, on the draft body, before repo.promoteDraft writes and before any seed row is loaded. It covers a seed draft stored before this change, one whose object was a draft at its save, and one whose object has since lost the field. publishPackageDrafts aborts the batch on it (outcome: 'refused'), and publishMetaItem refuses it.
  • The restore doors, through refuseRestoredBodyTheSaveDoorRefuses (rollbackMetaItem, revertCommit).

The boot loader warns, never refuses. In SeedLoaderService.loadDataset (seed-loader.ts), a keyed dataset whose every record has no value for its key logs one warn per dataset, naming the object, the key and the mode. Its writes are unchanged. A replay of such a stored seed on this head still inserts every record again (upsert, ignore) or skips it (update); the loader test pins that, so the behaviour the doors now stop is stated rather than implied.

Changeset .changeset/28952-seed-external-id-refusal.md: minor on @objectstack/metadata-protocol, both declaration lines, the BREAKING banner and the ADR-0087 not-required (no-migration-prescription) marker.

Tests

  • Step 1, the measurement (before any refusal). Every seed module in examples/app-showcase, app-crm, app-todo, app-multi-package, the platform packages and hotcrm (d4ffe99) was imported and each keyed seed's externalId checked against its object's fields plus the injected columns. 47 keyed seeds (showcase 19, crm 5, todo 1, hotcrm 22; the platform packages and app-multi-package ship none), all with a declared externalId: 0 dangling, 0 relying on an implicit name the object lacks. Two targeted platform objects the scan could not load were read by hand: sys_business_unit keyed on id (the primary key) and hotcrm's sys_activity keyed on ['source_object', 'source_id'] (both declared in plugin-audit).
  • New packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts (22 tests): the refusal at a draft save into a package, a draft save with no package and a publish save; each keyed mode; a composite with one bad member (externalId.1); an undeclared key on an object with no name; publishMetaItem and publishPackageDrafts on a seed draft stored before the rule (draft stays a draft, the batch promotes nothing, no seed row written); an object drafted in the same batch that lacks the field; the restore door. Controls: name, code, id, a composite of declared fields and an undeclared key on an object with name each save, publish (and load: the double records the seed row) and save live; insert and replace; an object drafted in the same batch that declares the field; an object neither registered nor drafted; a migrate-stored re-save.
  • New packages/metadata-protocol/src/seed-loader-empty-key-warn.test.ts (5 tests): one warn per load naming object and key, success: true, and a replay inserting the row again (2 rows); a composite member no record carries; controls: a declared key converging (inserted: 1, then inserted: 0, updated: 0) with no warn, an insert dataset and a dataset where some record carries the key.
  • protocol.restore-doors-save-refusal-family.test.ts: one more pinBothDoors case, a seed version keyed on a field its object has since lost, refused at rollbackMetaItem and revertCommit with nothing stored; its controls restore the accepted version. The harness's registry gained an objects map (empty for every other case).
  • New dogfood case packages/qa/dogfood/test/seed-external-id-refusal.dogfood.test.ts (4 tests, the card's repro over the real showcase and REST): the draft PUT /api/v1/meta/seed/r_ghost refused 422 INVALID_METADATA at externalId with nothing in _drafts; the composite refused at externalId.1; control: externalId: 'name' publishes inserted: 1, republishes inserted: 0, updated: 0, and GET /api/v1/data/seedkey_other serves one G1; control: an insert seed saves.
  • Red on base 62b3ec36. The three in-process files with the four source files restored to base: 16 failed, 47 passed, every red a refusal or warn pin and every control green. The dogfood case on a base build of @objectstack/metadata-protocol: 3 failed, 1 passed. Its control failed too, inserted: 2 instead of 1, because the ghost seed the base had accepted was published in the same batch: the card's duplication, measured.
Ablation (one-off, from committed HEAD)

The save door's check in saveMetaItem was disabled with node scripts/ablation-replace.mjs (if (seedKeyRefusal) throw seedKeyRefusal; → if (seedKeyRefusal) console.debug('ABLATION_28952_SAVE_DOOR');): anchor 1 → 0, blob 96642a86ae58 → 18dc7463b075. @objectstack/metadata-protocol was rebuilt and scripts/ablation-dist-preflight.mjs read the marker in 2 built files. Readings: the dogfood case 3 failed, 1 passed (the card and composite refusals, and the control, whose publish now carried the ghost seed and was refused by the publish promotion); protocol.seed-external-id-refusal.test.ts 8 failed, 14 passed (the save-door cases; the publish-door cases stay green, as they should). Restored: blob equals HEAD 96642a86ae58, git diff HEAD empty; rebuilt, the preflight --absent read the marker in 0 of 24 built files with the tree clean; the dogfood case passed 4/4 again.

A first attempt used void 'ABLATION_28952_SAVE_DOOR' as the marker. The bundler drops that expression, so the preflight read it as absent from dist even though the dogfood case went red; it was redone with the console.debug marker above, and only the second run's readings are quoted.

Acceptance notes

  • Envelope. The order first named 400 VALIDATION_ERROR; the seat then ruled one door, one shape, so the refusal answers with the unknown-datasource refusal's 422 INVALID_METADATA envelope at all three sites (commit 3d04cc7e).
  • "The publish probe". The L3 probe pass (runBuildProbes, probes.checked.seeds) runs after the publish has written and by contract never fails it, so it cannot refuse "before any write". The publish-side refusal therefore sits in promoteDraftForPublish, where every publish path promotes a draft and the sibling refusals already sit. build-probes.ts is unchanged.
  • Implicit name. An undeclared externalId is judged as name, because the loader keys on name and the duplication is the same. The measurement found no shipped seed relying on it.
  • Extension fields in the same batch. An object's registered definition includes its extension fields. An object_extension drafted in the same package batch is not in the batch closure this reads, so a seed keyed on a field only that pending extension adds would be refused at the batch publish. No shipped seed does this; noted, not filed.
  • Loader warn level. The order fixed warn. Under AGENTS.md's degradation rule the duplicate rows are a data-consistency outcome, but the doors now refuse such a seed at authoring and the boot must not fail, so the remaining case is a stored seed from before this change.

Verification

Head d9200288 (base 62b3ec36). origin/main moved 9 commits since the base, none touching packages/metadata-protocol, packages/qa/dogfood or the double ledger; the branch was not merged with it, and the merge queue tests the merged tree.

Package tests, typecheck, lint
  • pnpm --filter @objectstack/metadata-protocol test: 299 files passed (4 skipped), 22137 tests passed (26 skipped).
  • pnpm --filter @objectstack/metadata-protocol --filter @objectstack/dogfood typecheck: exit 0 (the metadata-protocol tsconfig includes src/**/*, test files too).
  • pnpm exec eslint --no-inline-config packages/metadata-protocol/src packages/qa/dogfood/test/seed-external-id-refusal.dogfood.test.ts: exit 0.
Dogfood battery (a narrowing)

pnpm --filter @objectstack/dogfood test under the verify lock, after the @objectstack/dogfood^... closure build: VERDICT command-exit 0, Test Files 384 passed (3 skipped), Tests 3167 passed (28 skipped), at b8d8083b. The one later commit, d9200288, changes only scripts/engine-double-contract.pinned.json, which no dogfood case reads.

Derived gates

node scripts/pm/dispatch-gates.mjs --commands on this diff named 73 families (65 before the ledger commit, 8 more after it). All 73 exit 0, and --ran answers: 73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN.

  • First pass, three non-zero exits, each fixed and re-run: check:engine-double-contract exit 1 (the two new test doubles were not pinned; --write, committed in d9200288, then exit 0); check-plugin-teardown-shape --self-test exit 3 (shallow clone; the fixture commit fetched, then exit 0); check:dual-build-cjs-loads exit 3 (prerequisite: not every package had dist/; after a full turbo run build, 118/118 tasks, exit 0: 115 require entry points across 70 packages load).
  • The 8 families the ledger commit added (check:agent-test-spelling, check:bash32-floor, check:cli-command-ids, check:entry-guard, check:parse-guard, check:pnpm-filter-targets, check:pnpm-flag-spellings, check:scripts-symbol-anchors): exit 0 at d9200288.
  • The other 62 ran at b8d8083b. CI runs the full farm on this head.

Generated by Claude Code

…o field of its object

The save door, the publish promotion and the restore doors refuse an
upsert/update/ignore seed whose externalId (each composite member; the
default `name` when undeclared) is not a field of the seed's object,
400 VALIDATION_ERROR at externalId. The boot loader never refuses: it
warns once for a keyed dataset whose every record keys empty.

Claude-Session: https://claude.ai/code/session_01FEkQ5pEjzdJdM36Fgh7YyY
Co-Authored-By: Claude <noreply@anthropic.com>
…d on a field its object lost

Claude-Session: https://claude.ai/code/session_01FEkQ5pEjzdJdM36Fgh7YyY
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 21 documentable anchor(s).

14 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 seedExternalIdSaveRefusal; 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 seedExternalIdSaveRefusal; 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 seedExternalIdSaveRefusal; 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 seedExternalIdSaveRefusal; 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 seedExternalIdSaveRefusal; a string literal on a changed line), externalId (literal, a string literal in seedExternalIdSaveRefusal))
  • content/docs/data-modeling/fields.mdx (via externalId (literal, a string literal in seedExternalIdSaveRefusal))
  • content/docs/data-modeling/seed-data.mdx (via externalId (literal, a string literal in seedExternalIdSaveRefusal))
  • content/docs/data-modeling/validation-rules.mdx (via externalId (literal, a string literal in seedExternalIdSaveRefusal))
  • content/docs/deployment/install-modes.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in seedExternalIdSaveRefusal; a string literal on a changed line))
  • content/docs/getting-started/examples.mdx (via externalId (literal, a string literal in seedExternalIdSaveRefusal))
  • content/docs/permissions/authorization.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in seedExternalIdSaveRefusal; 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 seedExternalIdSaveRefusal; 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 seedExternalIdSaveRefusal; 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 seedExternalIdSaveRefusal; 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 seedExternalIdSaveRefusal; 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 seedExternalIdSaveRefusal; 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 seedExternalIdSaveRefusal; 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 seedExternalIdSaveRefusal; 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 seedExternalIdSaveRefusal; 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 seedExternalIdSaveRefusal; 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
  • 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 — 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 5d3667e292c67642a99c2ce1b02950af9469a82f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5d3667e292c67642a99c2ce1b02950af9469a82f

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

…ave door's 422 INVALID_METADATA envelope

One door, one shape: the refusal now carries the envelope the unknown
datasource refusal uses (422 INVALID_METADATA, "TYPE/NAME failed spec
validation", issues at externalId / externalId.N with the prose in each
finding), at the save door, the publish promotion and the restore doors.

Claude-Session: https://claude.ai/code/session_01FEkQ5pEjzdJdM36Fgh7YyY
Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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

Contract-review-claim: session_01R1q2xdtYgU6FKrZ7LgjPCC · head 3d04cc7e44c0a2a3dcbc1447909fbe9e3845cca8 · 2026-11-02T07:13Z

Taken by the dispatching seat. Face narrowing (Clause-②: yes (narrowing), Metadata-protocol: narrowed), at CONTRACT_REVIEW_TIER with ④. The review runs as an isolated sub-agent.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3d04cc7e44c0a2a3dcbc1447909fbe9e3845cca8
Local-runs: none
Scope: packages/metadata-protocol/src/protocol-save-door-refusals.ts, packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol-authoring-gate.ts, packages/metadata-protocol/src/seed-loader.ts, packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts, packages/metadata-protocol/src/seed-loader-empty-key-warn.test.ts, packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts, packages/qa/dogfood/test/seed-external-id-refusal.dogfood.test.ts, scripts/engine-double-contract.pinned.json + packages/spec/src/data/seed.zod.ts, packages/spec/src/data/injected-system-columns.ts, packages/metadata-protocol/src/protocol-publish-batch.ts, packages/metadata-protocol/src/runtime-authoring-gate.ts, packages/metadata-protocol/src/build-probes.ts, packages/metadata-protocol/src/index.ts, packages/objectql/src/registry.ts, packages/lint/src/runtime-gate.ts, .changeset/26724-object-unknown-datasource-refused.md

Read at 2026-11-02T07:20Z: card #28952 (body, 3 comments), PR #29066 (v2 body, 10 files, net diff vs main 62b3ec3 merge-base), check-runs on the head. Rendered by the seat's subagent.

① Derived judgments

Accept-set changes the diff implies, each judged against the cited code at the head:

  1. saveMetaItem, draft and publish mode: a seed in upsert/update/ignore whose externalId member (undeclared = name) is not a field of a resolvable object, accepted before → refused 422 INVALID_METADATA, issues[] at externalId / externalId.N, code: 'custom', nothing stored. Right. Sits after the schema gate, beside unknownDatasourceSaveRefusal, before the authoring gate and every write (protocol.ts ~22645); same envelope and specValidationFindings headline as 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.
  2. promoteDraftForPublish (publishMetaItem, publishPackageDrafts): the same refusal on the draft body before repo.promoteDraft, judged against request.pending?.objects (the batch closure of collectBatchPendingDeclarations) else engine.registry.getObject. Right. The batch aborts outcome: 'refused' as the sibling refusals do; the test pins no seed row written.
  3. Restore doors (rollbackMetaItem, revertCommit) via refuseRestoredBodyTheSaveDoorRefuses: same refusal, host widened by one member. Right.
  4. migrate-stored re-save: unchanged (exempt). Right — platform healing of its own row, as the order's exemption.
  5. insert / replace: unchanged. Right — the loader never keys them.
  6. Object unresolvable: draft save with an object draft of that name (any package) → deferred to publish; neither registered nor drafted → not judged. Right — fail-open mirrors 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; the load reports a missing object.
  7. "Field" = object fields of the registered def (registry fold merges extension fields, registry.ts:792) ∪ the batch draft's fields ∪ resolveInjectedSystemColumns(pendingObject ?? registered).names (id always; tenant/audit/ownership per plan). Right, with one lean noted in ④(a).
  8. SeedLoaderService.loadDataset: no accept-set change; one warn per keyed dataset whose every record keys '' (via the loader's own externalIdKey); writes unchanged, pinned (replay inserts again). Right — the boot must not fail; loader defaultMode and SeedSchema.mode both default upsert, so judging an undeclared mode as upsert matches the spec.
  9. Public surface: seedExternalIdSaveRefusal is exported from protocol-save-door-refusals.ts and AuthoringGateHost gains a member, but neither module is re-exported by src/index.ts; the class method is private. No public-surface change. scripts/engine-double-contract.pinned.json is generated output (6 new pins), not reviewed.

② Semver level

Changeset .changeset/28952-seed-external-id-refusal.md: minor on @objectstack/metadata-protocol — the only released package the diff moves (@objectstack/dogfood is private: true; scripts/ publishes nothing). Metadata-protocol: narrowed (a value refused that was accepted) ⇒ a kind ⇒ at least minor per AGENTS.md rule 3. Matches. BREAKING banner, ! in the title, Before/Now/remedy, and exactly one ADR-0087 marker not-required (no-migration-prescription) — same shape as the #26724 sibling; no spec key moves, so no tombstone or FROM→TO is owed.
Clause-②: no (narrowing) — on the PR body (v2) and the changeset, one arm, and it is what the diff does: nothing widened.

③ Boundary flags

  • open_questions: none.
  • Envelope 400 → 422: the seat's ruling (one door, one shape) supersedes the order; the head answers 422 INVALID_METADATA at all three sites and the v2 body says so. Answered.
  • "The publish probe": runBuildProbes (build-probes.ts, ADR-0038 L3) runs after the publish has written and only reports; it cannot refuse before a write. Placing the publish-side refusal in promoteDraftForPublish beside the sibling refusals is the right reading; build-probes.ts untouched. Answered.
  • Implicit name: SeedSchema.externalId.default('name') and the loader's || 'name' agree; judging it is correct and the measurement found no shipped reliance. Answered.
  • object_extension drafted in the same batch (noted, not filed): CLOSURE_CONTEXT_KEY_BY_TYPE carries no extension type, and the draft-save exemption looks only at object drafts, so a seed keyed on a field only a pending extension adds is refused at save (object registered without it) and at batch publish. A valid composition refused, with a two-publish workaround. Escalated as should-fix (④b): file a card; not a blocker.
  • Branch not merged with origin/main (9 commits, none in touched packages): the merge queue tests the merged tree. Accepted.
  • Loader warn level under the degradation rule: the doors refuse at authoring, the boot must not fail; warn with the object/key/mode is the right level for a pre-rule stored seed. Accepted.
  • Dev report "73 families, all exit 0" vs CI: check:error-code-casing is red on this head (④ must-fix). The local run did not cover it; CI decides, and CI says red.

④ Design judgment

Spec on main: SeedSchema.externalId is "Field (or composite list of fields) matched for the uniqueness check" (seed.zod.ts:162), with the docblock naming framework#3434's replay duplication as what a key exists to prevent. A key naming no field was never a declared capability; the loader's externalIdKey returns '' for it and decideWriteAction inserts. The narrowing makes the spec's own requirement real at the consumer that can still name the fix (the author), and takes nothing declared away. Tried to break it:

  • (a) Same-batch object draft that drops the keyed field while the registered def still has it: fieldNames is the union of registered ∪ pending, so the seed is accepted. Leans fail-open (the union is what lets registered extension fields count beside a draft); not a wrong refusal. Note only.
  • (b) Should-fix — pending object_extension adding the keyed field (③ above): a correct seed is refused at save and at batch publish. Fix direction: skip the judgment when an object_extension draft targeting the object is pending (fail-open), or carry extensions into the closure. Card, not blocker.
  • (c) Closure read degrades (repo.get absent): pending undefined ⇒ registered only ⇒ never a false refusal. Fine.
  • (d) publishMetaItem of a seed alone while its object is only a draft: not judged (not registered, no batch); the load reports the object. Fine. With a stale registered def it is refused — correct, that publish would load against it.
  • (e) Finding length: the REST face truncates at 500 chars (refusals.ts:95), never refuses; the consequence clause plus three names stays under it for pattern-length names.
  • (f) Loader warn fires before reference resolution; resolution rewrites values in place, so an absent key field stays absent. At worst a spurious warn, no write changes.
  • (g) Must-fix — the head's check-run Lint · docs & code guards is failure: check:error-code-casing (ADR-0112 D1) flags packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts:260 records: [{ code: 'g' }] as a lowercase error-code emission, and lane-verdict keeps Lint & Repo Gates red (51/52). The literal is a seed record's code column, not an error code; the gate wants either an adr0112-ok: reason on that line or a fixture column that is not named code. Trivial, but a red gate on the head is a FAIL by the brief.

Check-runs at 2026-11-02T07:20Z: success — Check Changeset, PR-body guards, Governed Surface Queue Guard, Spec property liveness, Build Core, Test UI, Live E2E UI, Dogfood Regression Gate, Type Check · source gates / debt ledger, every Lint lane except docs & code guards; failure — Lint · docs & code guards, Lint & Repo Gates; still running — Test Core 1–8/8, Type Check · workspace, Type Check · consumer gates.

Implemented-by: claude/project-thread-ncjmkt
Reviewed-by: session_01R1q2xdtYgU6FKrZ7LgjPCC

VERDICT: FAIL


Generated by Claude Code

The fixture's text column was named code, so a record literal read as a
lowercase error code to the ADR-0112 casing gate. Renamed to sku; no
behaviour change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5pEjzdJdM36Fgh7YyY
@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-02T07:27Z

Head 9127f436, report 6570901716 (on #28952), claim 6569727725. Clause-②: no (narrowing), Metadata-protocol: narrowed, minor. The contract review on 3d04cc7e (6571500081) judged every accept-set change right and failed on one CI red only: check:error-code-casing flagged a test fixture column named code.

item reading
Delta 3d04cc7e..9127f436 One test-only commit, read by the seat: in protocol.seed-external-id-refusal.test.ts the fixture column code became sku (object fixture, records, externalId cases, the stored-row assertion). No source, changeset or body change, so nothing the review judged moves. pnpm check:error-code-casing exits 0; that file passes 22/22.
Left for later The review's should-fix (a seed keyed on a field that only a pending object_extension draft adds is refused) is recorded in the seat 1 handoff on #6367, not on this PR.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z), and 「先确保已有的pr都合并」 (Leehom, 2026-11-02T07:11Z): ready and auto-merge through the relay; CI on this head decides.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 9127f436332169631f997380f1a74079598e612f · 2026-11-02T08:54Z

Taken under references/contract-review.md rule (b): this head has waited over 60 minutes with no record, claim or hand-off on it (the lane's earlier record names an earlier head). At CONTRACT_REVIEW_TIER.


Generated by Claude Code

Merged via the queue into main with commit 3cf7f61 Nov 2, 2026
50 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-ncjmkt branch November 2, 2026 09:18
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9127f436332169631f997380f1a74079598e612f
Local-runs: none
Scope: packages/metadata-protocol/src/protocol-save-door-refusals.ts, packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol-authoring-gate.ts, packages/metadata-protocol/src/seed-loader.ts, packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts, packages/metadata-protocol/src/seed-loader-empty-key-warn.test.ts, packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts, packages/qa/dogfood/test/seed-external-id-refusal.dogfood.test.ts, scripts/engine-double-contract.pinned.json + packages/spec/src/data/seed.zod.ts (SeedSchema, defineSeed, assertSeedKeysDeclared), packages/spec/src/data/injected-system-columns.ts, packages/spec/src/shared/strict-object.ts, packages/metadata-protocol/src/protocol-publish-batch.ts, packages/metadata-protocol/src/runtime-authoring-gate.ts, packages/metadata-protocol/src/protocol-drafts.ts, packages/lint/src/validate-seed-replay-safety.ts, packages/lint/src/validate-field-consumers.ts, packages/objectql/src/registry.ts, packages/runtime/src/domains/packages.ts, docs/adr/0087-metadata-protocol-upgrade-contract.md, .changeset/26724-object-unknown-datasource-refused.md, AGENTS.md

Read: card #28952 (body, 5 comments), PR #29066 (body, 10 files, net diff vs main, 5 comments including the lane's FAIL record 6571500081 on 3d04cc7e and the seat's ACCEPT 6571560317), the 50 check-runs on this head (37 success, 13 skipped, none failed, none pending). Rendered by session_01AavokzJ5DndAwitDXvKy4U under contract-review rule (b).

The lane's FAIL record on 3d04cc7e, item by item against this head. (g) must-fix, check:error-code-casing red on the test fixture column code: cleared. The fixture is sku throughout protocol.seed-external-id-refusal.test.ts (object, records, externalId cases, the stored-row assertion), and Lint · docs & code guards and Lint & Repo Gates are both success on 9127f436. (b) should-fix, pending object_extension: still true on this head (CLOSURE_CONTEXT_KEY_BY_TYPE routes object, permission, book, dataset only; the draft-save deferral lists object drafts only); carried below as should-fix. Notes (a), (c)-(f): re-read, all still hold.

① Derived judgments

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

  1. saveMetaItem, draft and publish mode (REST PUT /api/v1/meta/seed/NAME, with and without mode=draft): a seed in upsert/update/ignore whose externalId member (undeclared = name) is not a field of a resolvable object, stored before → refused 422 INVALID_METADATA, issues[] at externalId / externalId.N, code: 'custom', headline seed/NAME failed spec validation: , nothing stored. Right. Placed after the schema gate and unknownDatasourceSaveRefusal, before the authoring gate and every write; same envelope as 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. The raw body is read, and that is safe: strictObject REFUSES an alias (key → externalId) at the schema gate with a prescription, never rewrites it, so no aliased body reaches this door and reads as "undeclared"; and applySeedBodies parses through SeedLoaderRequestSchema with defaultMode: 'upsert', so the defaults the door assumes (upsert, name) are the ones the load applies.
  2. promoteDraftForPublish behind publishMetaItem and publishPackageDrafts: the same refusal on the draft body, judged against request.pending?.objects (the batch closure, read ONCE before Phase 1 by collectBatchPendingDeclarations) else engine.registry.getObject. Right. The batch promotes inside one engine.transaction and a thrown refusal aborts it (outcome: 'refused', publishedCount: 0, the seed bodies are applied only after every promotion), which the object-plus-seed test pins with no rollback in its double, so the ordering itself carries it.
  3. Restore doors (rollbackMetaItem, revertCommit) via refuseRestoredBodyTheSaveDoorRefuses: host widened by seedExternalIdSaveRefusal. Right.
  4. duplicatePackage (copy/clone re-saves each item through saveMetaItem with no exempting source): a stored active seed of this shape in the source package now fails as one failed[] item of the per-item report, the rest copy. Right (the copy would duplicate rows the same way) — an accept-set change the PR body and changeset do not name; note, not a defect.
  5. migrate-stored re-save: exempt. Right — the platform healing its own row must not fail a boot or an os migrate meta --stored.
  6. insert / replace: not judged. Right — loadDataset never keys them (keyedMode is the same three modes).
  7. Object unresolvable: draft save with any object draft of that name (environment-wide, ADR-0131 D6) → deferred to publish; neither registered nor drafted → not judged. Right, fail-open; the load reports a missing object. (The listDrafts call runs before the registry read on every keyed draft-seed save; one list per save, acceptable.)
  8. "Field" = registered fields (the registry fold merges extension fields additively, mergeObjectDefinitions) ∪ the batch draft's fields ∪ resolveInjectedSystemColumns(pendingObject ?? registered).names (id always, tenant/audit/ownership per plan). Right; the union leans fail-open where a same-batch object draft drops the keyed field (④a).
  9. SeedLoaderService.loadDataset: no accept-set change; one warn per keyed dataset whose every record keys '', writes unchanged and pinned (replay inserts again). The condition is right (the loader's own externalIdKey; a reference object keys as [object Object], not '', so no spurious warn before resolution). The level is wrong — ④(g), must-fix.
  10. Public surface: seedExternalIdSaveRefusal is exported from protocol-save-door-refusals.ts and AuthoringGateHost gains a member, neither re-exported by src/index.ts; the class method is private. No public-surface change. scripts/engine-double-contract.pinned.json is generated (6 pins for the two new test doubles), not reviewed.
  11. The build: defineSeed (packages/spec) and os validate are unchanged. A code-declared seed with a dangling externalId is still accepted at the build and reaches the loader on every boot — ④(f).

② Semver level

Changeset .changeset/28952-seed-external-id-refusal.md: minor on @objectstack/metadata-protocol, the only released package the diff moves (@objectstack/dogfood is private: true; scripts/ publishes nothing). Metadata-protocol: narrowed (a value refused that was accepted) is a kind ⇒ at least minor under AGENTS.md rule 3. Matches. fix(...)!: title, BREAKING banner, Before/Now/What counts/Not judged/Who sees it/remedy, exactly one ADR-0087 marker, not-required (no-migration-prescription), with no FROM→TO block in the body — the same disposition and shape as the #26724 sibling, and the right one: no schema key is added, removed or re-shaped, and a migration cannot choose the field for the author. Check Changeset and the lint lanes carrying check:adr-0087-registration are green.
Clause-②: no (narrowing) on the PR body and in the changeset, one arm from the closed pair, and it is what the diff does: nothing is widened. (The seat's claim comment 6571352809 wrote yes (narrowing) once; the PR body and changeset, which the gates read, are right.)

③ Boundary flags

  • open_questions: none in the os-dev-report.
  • out_of_scope_findings — pending object_extension not in the batch closure: confirmed on this head; escalated as should-fix (④b), card, not blocker. Envelope 400 → 422: the seat's ruling (one door, one shape) supersedes the order; the head answers 422 INVALID_METADATA at all three sites. Answered. Branch not merged with origin/main: the merge queue tests the merged tree; mergeable_state: clean. Accepted.
  • "The publish probe": runBuildProbes (ADR-0038 L3) runs after the publish has written and never fails it, so it cannot refuse before a write; promoteDraftForPublish beside the sibling refusals is the right door, build-probes.ts untouched. Answered.
  • Implicit name: SeedSchema.externalId.default('name') and the loader's || 'name' agree; the measurement found no shipped seed relying on it. Answered.
  • Loader warn level: the dev's Acceptance note concedes "the duplicate rows are a data-consistency outcome" and keeps warn because the order fixed it and "the remaining case is a stored seed from before this change". That premise is false — see ④(g). Escalated as must-fix.
  • Upgraded deployments (asked of this review): stored seeds are NOT replayed at boot (seed rows land only at publish, applySeedBodies / applyPublishedSeeds; cold boot hydrates the metadata row, not the data). So a deployment whose sys_metadata already holds an active seed of this shape boots as before, os migrate meta --stored re-saves it untouched (exempt), and the author meets the refusal — with its remedy — at the next draft save, live save, publish or restore of that seed; a stored draft of this shape is refused at its publish and stays a draft to be edited. Nothing fails on upgrade, no further duplication is written, and rows duplicated before are left as they are (out of this card). No ADR-0087 migration entry is owed: no-migration-prescription is the correct disposition, as in 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.
  • Check-runs on 9127f436: every run completed, 37 success, 13 skipped (UI, docs, opt-in and matrix placeholders), none failure, none pending. The earlier red lane is green.

④ Design judgment

Spec on main: SeedSchema.externalId is "Field (or composite list of fields) matched for the uniqueness check", whose docblock names framework#3434's replay duplication as what the key exists to prevent; SeedMode default upsert; externalId default name. A key naming no field was never a declared capability — the loader's externalIdKey returns '' for it and decideWriteAction inserts. The narrowing makes the spec's own requirement real at the doors that can still name the fix, and takes nothing declared away. Tried to break it:

  • (a) Same-batch object draft that DROPS the keyed field while the registered def still has it: fieldNames is registered ∪ pending, so the seed is accepted and the published object lacks the field. Fail-open lean (the union is what lets registered extension fields count beside a draft). Note only.
  • (b) Should-fix — an object_extension drafted in the same package batch that ADDS the keyed field: the draft save sees the registered object without it and REFUSES (the deferral lists object drafts only), and the batch publish refuses too (no extension in the closure). A correct composition refused, with a publish-the-extension-first workaround; no shipped seed does this. Fix direction: defer when an object_extension draft targets the object, or carry extensions into the closure. Card.
  • (c) publishMetaItem of the seed alone while an object draft adding the field is unpublished: refused against the registered def. Correct — that publish would load against the object as registered — though the finding says "not a field of object X" where "not yet published on object X" is the truth. Note.
  • (d) Closure read degrades (repo.get absent): pending undefined ⇒ registered only ⇒ never a false refusal. Fine.
  • (e) Aliased keys, defaults, non-string members: the schema gate refuses aliases and non-string members before this door; the defaults match the load (①1). Fine.
  • (f) Should-fix — the build door. defineSeed already runs assertSeedKeysDeclared(objectDef, records, columns) and refuses a record key or columns key the object lacks, against exactly the set this refusal uses (declared fields + resolveInjectedSystemColumns); externalId is the one object-field reference it does not judge, and os validate's validateSeedReplaySafety flags insert only (validateFieldConsumers credits externalId as a reader, it reports nothing dangling). So the card's defect stays reachable on the code-declared path — the path every one of the 47 measured seeds takes — with the boot line of (g) as its only diagnosis. The right fix is the same assertion for externalId members in defineSeed, a packages/spec governed-surface change with its own tier; the order forbade packages/spec and the card's fix direction named the save/publish doors, so this is a card, not a blocker.
  • (g) Must-fix — the loader's level. AGENTS.md "Degradation log levels" decides with one question: after the degradation, does the system look normal while something it claims to persist has not landed? Here an upsert load reports success: true, inserted: N and the table gains N rows on every load — a data-consistency degradation, which the rule puts at error with consequence and fix in the first line (the message already carries both). The same file applies the rule to the LESSER sibling: warnOnUndeclaredSeedColumns logs error for a columns key the object lacks, its docblock citing the rule. The PR's reason for warn ("the remaining case is a stored seed from before this change") does not hold: by (f) a code-declared seed never passes any door this PR adds and reaches this line on every boot, so this line is the only signal on the common path. Fix: this.logger.error(...) at seed-loader.ts (the [#28952] block in loadDataset), the docblock there and in seedExternalIdSaveRefusal, and seed-loader-empty-key-warn.test.ts asserting logger.error. In-PR, one site.
  • (h) Finding length under the 500-character REST cap holds for pattern-length names; the face truncates, never refuses. Fine.
  • (i) Security: the draft-save deferral reads environment-wide drafts and the finding echoes only the author's own externalId and object strings; nothing crosses an organization, nothing is disclosed. No security note.

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

VERDICT: FAIL


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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

metadata-protocol: an upsert seed whose externalId names a field the object lacks is accepted and inserts a duplicate row on every publish, silently

1 participant