Skip to content

fix(spec): the ImportRequest.matchFields describe names the multi-value or structured match-key refusal - #27658

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-weezeu
Oct 29, 2026
Merged

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

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by 田騏 · project thread

Fixes #27583

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:ImportRequestSchema.matchFields 的 describe 补了一句:update / upsert 的匹配键若指向多值或结构化字段(multiple: true 的 select/radio/lookup/user/file/image、multiselect/checkboxes/tags、json/composite/repeater/record/location/address/vector),导入入口在读任何一行之前以 400 INVALID_REQUEST 拒绝并点名字段;绕过入口直接进 import runner 的调用方(mapping 的 connector pull)每行以 VALIDATION_ERROR 失败。同句写明命名 mapping 的 upsertKey 作键时同样判定,补救是用单值字段做键。API 参考页随之重生成,附 patch changeset。
  • 为什么改:fix(rest, core)!: refuse a multi-value or structured field as an import match key (#27465) #27562 落地后服务端已经这样拒绝,但 JSON schema 与 API 参考里的 describe 只字未提,集成方或 AI 作者按 describe 选 matchFields 只能在被拒时才知道。
  • 风险与回滚:纯文本,不改 schema、不改接受集、不改服务端回答;回滚即 revert 本 PR。
  • 先看:packages/spec/src/api/export.zod.ts(describe 新增的那一句);.changeset/27583-import-match-fields-describe-multi-value-key.md;content/docs/references/api/export.mdx(生成件,两行)。
  • 两分钟验证:pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:docs,预期 232 generated files in sync;或打开 content/docs/references/api/export.mdx 第 56 行,应看到 400 INVALID_REQUEST 与 VALIDATION_ERROR 两处。
  • 需要你做的:无,席位落地。

What changes

One sentence appended to the matchFields describe of ImportRequestSchema (packages/spec/src/api/export.zod.ts), written to the code the base already runs:

  • In update / upsert, a key naming a field that holds a list or a structured value is refused with 400 INVALID_REQUEST before any row is matched or written, naming each such field. The request's matchFields and the named mapping's upsertKey it falls back to are judged the same way (packages/rest/src/import-prepare.ts, prepareImportRequest, via multiValueMatchKeyRefusal).
  • Which fields: the population the spec's own arbiters define — isMultiValueField (a multiple: true select, radio, lookup, user, file or image field; a multiselect, checkboxes or tags field) and STRUCTURED_JSON_TYPES (json, composite, repeater, record, location, address, vector), packages/spec/src/data/field-value.zod.ts.
  • The remedy: match on single-value fields.
  • A caller that hands rows to the import runner without the door (a mapping's connector pull) gets every row failed with VALIDATION_ERROR and the same message (packages/core/src/utils/import-runner.ts, runImport).

content/docs/references/api/export.mdx is regenerated by pnpm --filter @objectstack/spec gen:docs (two matchFields rows: the sync import request and CreateImportJobRequestSchema, which is the same object). Changeset: patch on @objectstack/spec, text only.

Why

Measured on 178b5238: the describe (L295–L311) said nothing about the refusal; the server path reads the request's matchFields by hand in prepareImportRequest (no Zod parse of ImportRequestSchema on that path, so the describe documents the published contract and enforces nothing), and the refusal landed in PR #27562 (commit f557d90f, an ancestor of the base). The published JSON schema and the generated API reference carry the describe, so a caller reading them was not told about a 400 the server answers. content/docs/data-modeling/import-mappings.mdx already states both halves (L263, L282); the describe now agrees with it.

Tests

No behaviour changes, so no new test. Run on the affected package:

On the final head e40c42dd, each under scripts/pm/os-verify-lock.sh (verdict = the lock's VERDICT command-exit line):

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/api/export.test.ts: Test Files 1 passed (1), Tests 57 passed (57); command-exit 0.
  • pnpm --filter @objectstack/spec typecheck: command-exit 0 (1m24s).
  • pnpm --filter @objectstack/spec test: Test Files 752 passed (752), Tests 22343 passed | 1 todo (22344); command-exit 0 (4m24s).

No ablation: the diff changes no tested behaviour (describe text, a changeset, a regenerated reference).

Acceptance notes

  • ImportValidationConfigSchema.deduplication.matchFields (same file, L115) is left as it is: git grep deduplication over packages/rest/src, packages/core/src, packages/plugins, packages/services finds no reader, so it is not the key the door judges. A dormant describe, not a finding (noted, not filed; carrier: none).
  • ui/packages/types/src/data.ts L1129 carries a one-line TS docblock mirror of the describe's first sentence (matchFields?: string[], already behind the key-set union). A TypeScript type comment on the UI side, not a metadata surface and not this card's file; noted, not filed (carrier: none).
  • An insert import matches nothing and is not judged; the sentence scopes itself to update / upsert as the card asked.

Verification

Local gates on e40c42dd, exit codes captured to a file before any pipe. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 97 families from the three changed files; every one was run.

  • 94 exit 0, among them spec check:docs (✅ 232 generated files in sync with packages/spec), check:api-surface, check:authorable-surface, check:export-origins, check:llms-txt, check:variant-docs, check:yaml-examples, check:spec-parsed-alias, check:spec-docblock-symbol-anchors, check:doc-authoring, check:nul-bytes, check:issue-citations, check:closed-ceremony-text, check:published-files, check:merge-driver, check:pm-prior-rulings, check-adr-0087-registration --base origin/main, check-empty-changeset --base origin/main, check-changeset-no-major --base origin/main, check:changeset-gate-self-tests, check-adr-links, check-doc-route-spelling --advisory, the docs-audit scripts and the --self-test batteries.
  • 3 NOT MEASURED, prerequisite not met (exit 3: they import every published package's built dist/): spec check:skill-examples, pnpm check:dual-build-cjs-loads, pnpm check:lean-entry-closure. CI's Lint & Repo Gates runs them.
  • dispatch-gates --ran: "97 derived, 94 run, 3 NOT-MEASURED, 0 UNRUN".
  • pnpm --filter @objectstack/spec check:generated after the build on the merged head: 1 of 14 stale (check:docs), regenerated with gen:docs (two rows), then all 14 up to date (exit 0).
  • The full battery (pnpm lint, Test Core shards, the UI jobs) is CI's and was not re-run locally.
  • origin/main (efa2915e) merged once before opening, as 4be9eb37 via scripts/pm/os-regen-merge.sh; packages/spec and content/docs/references had not moved on main since the base, so no generated-file conflict.
  • Independent full review at CONTRACT_REVIEW_TIER (an isolated subagent, not the drafter): PASS on 29c63f99 with no must-fix and no should-fix; two of its optional notes applied in d19be4c9; delta review PASS on e40c42dd. Both records are posted as a comment on this PR.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind. (No name added.)
  • 3. No formatter ran; pnpm lint is CI's.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 block is the repo's own Chinese reader section).
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…ue or structured match-key refusal

An update / upsert import whose match key names a field that holds a
list or a structured value is refused at the import door with 400
INVALID_REQUEST, and the import runner fails every row with
VALIDATION_ERROR for a caller that reaches it without the door; the
describe said nothing about either. It now names the refusal, the
fields it covers (the spec's isMultiValueField and STRUCTURED_JSON_TYPES
population), the remedy and the runner's answer. Text only.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2cAD3GsST8mQKFsbJZ
…un and names the same refusal for the runner

Two review notes applied: the door refuses on a dry run as on the
import, and the runner answers the same refusal (its message prefix
names the key source, so "the same message" was loose).

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

Copy link
Copy Markdown
Contributor Author

domain:spec#4 · marchtian · session_017ihL2cAD3GsST8mQKFsbJZ

Independent full review (project rule: a spec change gets a complete review at CONTRACT_REVIEW_TIER before the seat's record)

Reviewer: an isolated subagent at CONTRACT_REVIEW_TIER (effort xhigh), not the drafter, fed the card, the triage ruling, the governing text (AGENTS.md, os-dev.md) and the code the describe cites — not the drafter's conclusions. Round 1 on head 29c63f99 (PASS, no must-fix, no should-fix, 6 optional notes; notes 1 and 4 were applied in d19be4c9), then a delta review by the same reviewer on the final head e40c42dd (PASS). Both records follow, verbatim apart from angle-bracket shapes spelled out in words.


Independent review — objectstack #27583 (ImportRequest.matchFields describe, packages/spec)

Head reviewed: 29c63f9995f7063000e52aa2543e3d2b91ddfd31 (branch claude/project-thread-weezeu, two commits 692e13e2 + 29c63f99), base 178b5238. f557d90f (PR #27562) is an ancestor of the base, so the behaviour the sentence describes is on the base. Worktree clean (git status --porcelain empty). Diff is exactly three files: packages/spec/src/api/export.zod.ts (+9/-1), .changeset/27583-import-match-fields-describe-multi-value-key.md (new), content/docs/references/api/export.mdx (two rows). No CHANGELOG.md, no content/docs/releases/ touched.

Must-fix

None.

Should-fix

None.

Notes (nothing false; each is optional tightening or an observation)

  1. "and the same message" is the same refusal text, not always the same bytes. The runner calls the rule with no keyedBy (packages/core/src/utils/import-runner.ts:1403: multiValueMatchKeyRefusal(matchFields, metaMap)), so its rows always open matchFields names "…". The only door-less caller, the connector pull, sources its key from artifact.upsertKey (packages/services/service-automation/src/connector-pull.ts:295-296), for which the door would open Mapping "NAME" upsertKey names "…" (packages/rest/src/import-prepare.ts:593, :721). The text after the prefix is byte-identical (one template, import-runner.ts:730-734). The describe's claim is true of the refusal and loose only about the prefix; "the same refusal" would be exact. The prefix quirk itself is the runner's, pre-existing and outside this card.

  2. Readability. The addition is one ~560-character sentence carrying 16 type names, an em-dash parenthetical and a semicolon clause, appended to a describe that is now 1,663 characters. It is in the house style of its neighbours (runAutomations, treatAsHistorical are of similar length) and the card asked for one sentence; splitting at the semicolon ("…(match on single-value fields: a code, a name, an email). A caller that hands rows…") would cost nothing and read easier. Optional.

  3. Naming isMultiValueField / STRUCTURED_JSON_TYPES in a JSON-schema describe. Both are public exports of @objectstack/spec (packages/spec/src/data/index.ts:201 re-exports field-value.zod), the card's Expected names them, and the concrete type list precedes them, so an integrator in another language still gets the list. Fine; just noting that for that reader the two identifiers are inert.

  4. Dry run not mentioned. The door refuses on a dry run too (packages/rest/src/import-multi-value-match-key.test.ts:131-138, upsert, dry run / update, dry run), and the preceding DUPLICATE_MATCH_KEY sentence says "on a dry run as on the import" while this one does not. Not a false claim; an optional four words.

  5. Route spelling in the changeset. POST /data/:object/import / POST /data/:object/import/jobs versus the ledger's POST /api/v1/data/:object/import (packages/rest/src/rest-route-ledger.ts:367-368). The test header (import-multi-value-match-key.test.ts:8) uses the short form, .changeset/25582-import-row-every-cell-error.md the long form; mixed precedent, not wrong.

  6. Out of scope, pre-existing: ui/packages/types/src/data.ts:1129 types matchFields?: string[] only, with no string[][], and already omits every earlier refusal sentence (key sets, BLANK_MATCH_KEY, AMBIGUOUS_MATCH, DUPLICATE_MATCH_KEY). Leaving it untouched here is consistent; its shape staleness is a separate card if anyone wants it.

What I verified, claim by claim (brief question 1)

The new sentence (packages/spec/src/api/export.zod.ts:310-318), checked against the base code:

  • "In update / upsert … is refused" — door: packages/rest/src/import-prepare.ts:720-723, if (writeMode !== 'insert') { const refusedKey = multiValueMatchKeyRefusal(matchFields, metaMap, matchFieldsFrom); if (refusedKey) return { ok: false, status: 400, code: 'INVALID_REQUEST', error: refusedKey.message }; }. Insert is not judged (rest test "preserved: an insert import matches nothing", core test likewise). True.
  • "a key (this field, or the named mapping's upsertKey it falls back to)" — import-prepare.ts:541-546 reads body.matchFields via readMatchFieldsBody; :591-593 if (matchFields.length === 0 && Array.isArray(mappingArtifact.upsertKey)) { matchFields = mappingArtifact.upsertKey.filter(...); matchFieldsFrom = \Mapping "${mappingName}" upsertKey`; }`. Fallback only when the request supplied none, which is what "falls back to" says. Pinned by rest test "a named mapping whose upsertKey names the lookup is refused the same way". True.
  • "a multiple: true select, radio, lookup, user, file or image field" — packages/spec/src/data/field-value.zod.ts:365-367 MULTI_CAPABLE_TYPES = ['select','radio','lookup','user','file','image'], gated by def.multiple === true in isMultiValueField (:385-388). Exact match, same order.
  • "a multiselect, checkboxes or tags field" — :146-148 MULTI_OPTION_TYPES = ['multiselect','checkboxes','tags']. Exact.
  • "a json, composite, repeater, record, location, address or vector field" — :350-352 STRUCTURED_JSON_TYPES = ['json','composite','repeater','record','location','address','vector']. Exact. The door's map carries every field with a string type and no type filter (packages/core/src/utils/import-field-meta.ts:84-96, including multiple: f.multiple === true), so each of these reaches the rule at the door, not only in the hand-built-map unit test. The rule reads both sets (import-runner.ts:723-727).
  • "400 INVALID_REQUEST" — import-prepare.ts:722. Pinned through the real route over better-sqlite3 by import-multi-value-match-key.test.ts:118-121 (expectRefusedUntouched: status 400, code INVALID_REQUEST, results undefined, no findData / updateData / createManyData call, table unchanged).
  • "before any row is matched or written" — the refusal sits after rows are parsed (:640-665) and the meta map resolved (:680-717) but before runImport, which is the only place rows are matched (findData) or written. The word choice is exactly right: rows have been read (parsed) by then, and the describe does not say "read". True.
  • "naming each such field" — import-runner.ts:716-727 collects every offending name over matchFields.flat() (so key sets too) into named[] and joins them (:731). Rest test "every other JSON-stored kind is refused too, and every offending field is named in one answer" asserts topics, labels, meta all appear.
  • "(match on single-value fields: a code, a name, an email)" — the message's own remedy, import-runner.ts:733: 'Match on single-value fields (a code, a name, an email) instead.'. True.
  • "a caller that hands rows to the import runner without this door (a mapping's connector pull) gets every row failed with VALIDATION_ERROR" — import-runner.ts:1399-1412: for writeMode !== 'insert', every row gets { ok: false, action: 'failed', field: refusedKey.fields[0], code: 'VALIDATION_ERROR', error: refusedKey.message } and the loop never starts. Non-test runImport callers: the two REST routes (data-action-routes.ts:471, :646) both go through prepareImportRequest (:448, :562); plugin-auth/src/admin-import-users.ts:756 also goes through it (:565); only connector-pull.ts:417 reaches the runner without the door (it uses applyMappingToRows + runImport directly). So "(a mapping's connector pull)" is the complete list. True. Pinned by import-runner-multi-value-match-key.test.ts (every row VALIDATION_ERROR, field = first offender, no protocol call).

Scope (brief question 2)

Everything the card's Expected asked for is present: the refusal, the 400 code, the arbiters by name, "naming each such field", and the runner's per-row VALIDATION_ERROR for the connector pull. Beyond the card's literal Expected the sentence adds the upsertKey fallback and the remedy; both are true (above), both are what content/docs/data-modeling/import-mappings.mdx:263 and :282 already state, and the card's reach ("the matchFields describe") covers them. Nothing else moved: insert is not judged (card: "insert is not judged"), no schema, default or accepted value changed.

The two untouched copies are right to leave:

  • ImportValidationConfigSchema.deduplication.matchFields (export.zod.ts:115-116) is a different contract (strategy + matchFields: string[].min(1)) with no consumer anywhere in packages/ (only packages/spec/dist/ declarations). prepareImportRequest never reads it. Writing the refusal there would advertise an enforcement that does not run through that schema — the Prime Directive chore: version packages #10 trap the card is avoiding.
  • ui/packages/types/src/data.ts:1129 is a hand-mirrored UI type that already omits every prior refusal sentence; the card's reader is "the domain:spec lane … one sentence in the matchFields describe". See note 6.

Changeset (brief question 3)

.changeset/27583-import-match-fields-describe-multi-value-key.md:

  • Package / level: '@objectstack/spec': patch — correct; the diff moves only packages/spec, and Metadata-protocol: none ⇒ patch per Post-Task Checklist step 3 ("none takes patch").
  • Declaration lines, line-initial, as the checklist requires: Clause-②: no (true: no accept-set change; no (widening)/(narrowing) arm, correctly, since nothing widened or narrowed) and Metadata-protocol: none (true: no key, value or meaning changed for an author).
  • Title fix(spec): the \ImportRequest.matchFields` describe says a multi-value or structured field is refused as a match key` — matches the card's defect ("describe does not say") and the diff.
  • Body: every claim checked. Routes exist (rest-route-ledger.ts:367-368; CreateImportJobRequestSchema = ImportRequestSchema, export.zod.ts:506, so one describe serves both bodies); "ships in the published JSON schema" holds (json-schema is in the spec package's files; gen:schema builds it from the describe); the field list matches the arbiters verbatim; the upsertKey fallback, remedy and runner answer are as verified above; "Text only: no schema, accepted value or server answer changes" is what the diff shows.
  • GitHub-byte traps: no angle brackets anywhere in the changeset or the describe addition (grep '[ANGLE-BRACKET-SHAPE]' empty on both); no HTML comment; no tracker number in the describe (grep '#' on the added lines empty), as os-dev.md L20 / L319 / L322 require. The changeset body carries none either.

Regenerated reference (brief question 4)

  • content/docs/references/api/export.mdx changes exactly two rows, L56 (ImportRequestSchema) and L314 (CreateImportJobRequestSchema, the alias). A third matchFields row at L440 is the dedup-config schema and is untouched, correctly.
  • I rebuilt the describe string from the TS source (joined the string literals, unescaped \') and compared it to both mdx cells: byte-equal (length 1,663) for both. So the author regenerated rather than hand-edited.
  • pnpm --filter @objectstack/spec check:docs (the repo's own --check gate, AGENTS.md § Touched packages/spec): ✅ 232 generated files in sync with packages/spec, exit 0.
  • The other artifact the describe reaches, packages/spec/json-schema/{api/ImportRequest.json, api/CreateImportJobRequest.json, objectstack.json}, is gitignored (.gitignore:63), zero tracked files, built by gen:schema at build/CI; the local copies already carry the new sentence. Nothing else in the repo holds the describe text except the hand-written doc, the two CHANGELOG.md/release pages (release-owned, untouched, correct) and a test fixture.
  • Nothing else was regenerated (api-surface, authorable-surface, skill docs all unchanged in the diff, and none should be for a describe-only change).

Would an author avoid the refusal now? (brief question 5)

Yes. Reading only the describe, an author or an agent learns: which modes it applies to (update / upsert), which 16 field kinds cannot be a key (each named, with the multiple: true qualifier where it matters), that the named mapping's upsertKey is judged the same way, the exact status and code, that the answer names every offending field, and the remedy. The sentence is long (note 2) but structured, and it is accurate to the letter of the arbiters and the door.

Verdict: PASS
Head sha reviewed: 29c63f9


Delta review — objectstack #27583, head e40c42dda02beeebde47928540baf6c71e09aae9

Previous head reviewed: 29c63f99 (round 1 above, PASS). Delta: three commits, read from the repo, not the summary.

  • d19be4c9 (parent 29c63f99) — edits the describe only (packages/spec/src/api/export.zod.ts:315-319).
  • 4be9eb37 — merge, parents d19be4c9 + efa2915e (efa2915e = origin/main now, and git merge-base origin/main e40c42dd = efa2915e, so the branch is current with main).
  • e40c42dd (parent 4be9eb37) — regenerates content/docs/references/api/export.mdx only (git show --stat: 1 file, 2 insertions, 2 deletions; the four changed lines are the two matchFields rows, L56 and L314).

Worktree at e40c42dd is clean (git status --porcelain empty).

Must-fix

None.

Should-fix

None.

Notes

  1. The two-dot delta diff is wider than the PR. git diff 29c63f99..e40c42dd -- packages/spec content/docs/references .changeset lists eight additional .changeset/*.md files (27243-…, 27480-…, 27483-…, 27487-…, 27498-…, 27512-…, 27514-…, 27515-…). They are main's own changesets carried in by the merge, not PR changes: git diff --stat origin/main...e40c42dd is exactly the three PR files (.changeset/27583-import-match-fields-describe-multi-value-key.md +10, content/docs/references/api/export.mdx +2/-2, packages/spec/src/api/export.zod.ts +10/-1), and git diff --stat d19be4c9 4be9eb37 -- THE-THREE-PR-FILES is empty, so the merge altered none of them. Nothing to do; recorded so nobody reads the two-dot listing as scope creep.

  2. Changeset unchanged, still true. git diff --stat 29c63f99 e40c42dd -- .changeset/27583-… is empty. It never carried the "same message" wording (grep count 0) and it does not mention the dry run; "refused at the import door with 400 INVALID_REQUEST before any row" remains true as written. Adding "on a dry run as on the import" there would be optional symmetry with the describe, not a correction.

  3. Source line re-flow (cosmetic). The edit leaves 'a caller that hands rows to ' + on a line of its own (export.zod.ts:317) between two full-width lines. No lint rule governs line width here (no max-len in any eslint config), the string concatenates correctly (my independent re-extraction below reproduces the mdx cell byte for byte), and the generated artifact is unaffected. Purely a tidy-up if the file is touched again.

  4. Correction to my first review's note 2. I estimated the added sentence at ~560 characters; measured from In \update`to the end it is now 786 characters (describe total 1,694, up from 1,663 by exactly the 31 characters ofon a dry run as on the import, ; message→refusal` is length-neutral). The readability point stands as optional and unchanged in kind; the number was wrong.

The two text edits, judged against the code on the merged head

The files the sentence describes did not move between the base and e40c42dd — git diff --stat 178b5238..e40c42dd -- packages/rest/src/import-prepare.ts packages/core/src/utils/import-runner.ts packages/core/src/utils/import-field-meta.ts packages/spec/src/data/field-value.zod.ts packages/services/service-automation/src/connector-pull.ts packages/rest/src/data-action-routes.ts packages/plugins/plugin-auth/src/admin-import-users.ts packages/rest/src/import-multi-value-match-key.test.ts packages/core/src/utils/import-runner-multi-value-match-key.test.ts content/docs/data-modeling/import-mappings.mdx is empty — so every line reference from round 1 still holds here.

Edit A — "… before any row is matched or written, on a dry run as on the import, naming each such field …" (export.zod.ts:315-316). True at the door: prepareImportRequest reads dryRun once (packages/rest/src/import-prepare.ts:533, const dryRun = body?.dryRun === true;) and only passes it through in prepared (:748); the refusal at :720-723 is gated on writeMode !== 'insert' alone, with no dryRun condition, and both routes reach it the same way on a preview as on a commit (data-action-routes.ts:448, :562). Pinned through the real route by import-multi-value-match-key.test.ts:131-138, whose it.each rows include ['upsert, dry run', { writeMode: 'upsert', dryRun: true }] and ['update, dry run', { writeMode: 'update', dryRun: true }], each asserting status 400, INVALID_REQUEST, no read, no write, table unchanged. The door's own comment says the same (import-prepare.ts:716-717: "on the dry run and the commit alike"). The phrase is also the one the preceding DUPLICATE_MATCH_KEY sentence uses, so the describe is now internally consistent. The runner side holds too, for completeness: the prelude at import-runner.ts:1403 has no dryRun gate, and import-runner-multi-value-match-key.test.ts runs ['upsert, dry run', 'upsert', true].

Edit B — "… gets every row failed with VALIDATION_ERROR and the same refusal instead." (export.zod.ts:319). This is the tightening my note 1 asked for, and it is exact: the runner's per-row error is refusedKey.message from the very same multiValueMatchKeyRefusal call the door uses (import-runner.ts:1403-1408), with code: 'VALIDATION_ERROR' and field: refusedKey.fields[0]. "The same refusal" is true whatever keyedBy prefix the door would have used (matchFields vs Mapping "NAME" upsertKey, import-prepare.ts:546/:593), which is what made "the same message" loose. Pinned by import-runner-multi-value-match-key.test.ts (error: refusal where refusal = multiValueMatchKeyRefusal(['work_object'], metaMap)!.message). The parenthetical "(a mapping's connector pull)" remains the complete list of door-less runImport callers (connector-pull.ts:417 is the only non-test caller not preceded by prepareImportRequest; the two routes and admin-import-users.ts:756 all go through the door).

No other wording in the describe changed (the diff touches only these two spans). No angle brackets and no # in the describe (regex scan of the re-extracted string: both false); the card/os-dev rules on angle brackets and tracker numbers still hold.

Regenerated reference

  • Rebuilt the describe string from e40c42dd:packages/spec/src/api/export.zod.ts (joined literals, unescaped \') and compared with both ImportRequest-shaped matchFields rows of e40c42dd:content/docs/references/api/export.mdx: L56 equal: true, L314 equal: true (length 1,694). The third matchFields row (dedup-config schema) is untouched, correctly.
  • pnpm --filter @objectstack/spec check:docs on the checked-out head e40c42dd: ✅ 232 generated files in sync with packages/spec, exit 0.
  • Main did not move packages/spec or content/docs/references between the base and the merge (git diff --stat 178b5238..efa2915e -- packages/spec content/docs/references empty), which is why the regenerate on the merged head changed only the two rows this PR owns and nothing else needed regenerating. packages/spec/json-schema/ remains gitignored and is rebuilt by gen:schema in CI.

Merge hygiene

Plain two-parent merge, no PR-file changes in the merge commit (above), whole-PR diff still three files, CHANGELOG.md and content/docs/releases/ untouched, branch current with origin/main.

Verdict: PASS
Head sha reviewed: e40c42d

@github-actions github-actions Bot added documentation Improvements or additions to documentation tooling labels Oct 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 167 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 b761968d9df2a2a72bbbdb96b4f67ca78a58739f → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#4 · marchtian · session_017ihL2fi9zfJhUTubyJYb6V

席位核对 · ACCEPT · card #27583 · 2026-10-29T16:48Z

  • Shape: 3 files, +22 / −3: one sentence appended to the ImportRequestSchema.matchFields describe (packages/spec/src/api/export.zod.ts), the two regenerated rows in content/docs/references/api/export.mdx (ImportRequestSchema and its alias CreateImportJobRequestSchema), and a @objectstack/spec patch changeset with Clause-②: no / Metadata-protocol: none. Published-schema face hit (describe text) ⇒ the contract review below is at CONTRACT_REVIEW_TIER; no governed path ⇒ not Tier H.
  • Premise: PR fix(rest, core)!: refuse a multi-value or structured field as an import match key (#27465) #27562 (the refusal) is an ancestor of this head; ImportValidationConfigSchema.deduplication.matchFields has no server reader and is rightly left alone.
  • Independent reviews: the dev's Fable review PASS + delta PASS (6502547965); the seat's at-tier contract review below, every claim in the sentence read against the head's code.
  • Gates: the dev's 97 derived, 94 green, 3 NOT MEASURED locally (need every package's dist; CI's). The landing waits for every check on this head to be green.
  • Out of scope: none filed. The reviewer's non-blocking reading (a key naming no field of the object is judged by nobody) describes behaviour that predates this PR and is outside the card.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#4 · marchtian · session_017ihL2fi9zfJhUTubyJYb6V

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e40c42dda02beeebde47928540baf6c71e09aae9
Local-runs: none
Scope: packages/spec/src/api/export.zod.ts + packages/rest/src/import-prepare.ts, packages/core/src/utils/import-runner.ts, packages/spec/src/data/field-value.zod.ts, packages/core/src/utils/import-field-meta.ts, packages/services/service-automation/src/connector-pull.ts, packages/rest/src/data-action-routes.ts, packages/plugins/plugin-auth/src/admin-import-users.ts, packages/rest/src/import-multi-value-match-key.test.ts, content/docs/data-modeling/import-mappings.mdx, content/docs/references/api/export.mdx, .changeset/27583-import-match-fields-describe-multi-value-key.md

① Derived judgments

  • Accept set unchanged: the only packages/spec/src/** hunk is inside the .describe(...) string of ImportRequestSchema.matchFields (export.zod.ts L310–L319); the Zod shape at L295–L298 is untouched. Public surface unchanged; CreateImportJobRequestSchema = ImportRequestSchema (L507) is why the reference changes two rows.
  • Each claim of the sentence holds on the head: update / upsert only (import-prepare.ts L720, import-runner.ts L1403); the upsertKey fallback (import-prepare.ts L591–L594, keyedBy at L721); the multiple: true types = MULTI_CAPABLE_TYPES (field-value.zod.ts L365–L367, read by isMultiValueField L385–L388); multiselect / checkboxes / tags = MULTI_OPTION_TYPES (L146–L148); the structured list = STRUCTURED_JSON_TYPES (L350–L352), exact and in order; the rule tests only those two arbiters (import-runner.ts L723, L725); 400 INVALID_REQUEST (import-prepare.ts L722, answered by data-action-routes.ts L454–L457 and L567–L570); before any row is matched or written (L720–L723, before the prepared return at L743); dry-run parity (the refusal does not read dryRun; test L134, L136); every such field named (import-runner.ts L719–L736; test L122–L125); the remedy (L738); the connector pull is the one runner caller without the door (connector-pull.ts L417–L432) and gets every row failed with VALIDATION_ERROR and the same message (import-runner.ts L1403–L1413).
  • Consistent with content/docs/data-modeling/import-mappings.mdx L263 / L282; the describe is the stricter enumeration.

② Semver level

Patch on @objectstack/spec (describe text only; the refusal itself landed in #27562, merge f557d90f, an ancestor of this head). The changeset says '@objectstack/spec': patch, "Text only", and carries the same Clause-②: no / Metadata-protocol: none lines as the PR body.

③ Boundary flags

  • Dev flags: none (open_questions: []). Author objectstack-fleet[bot] (relay).
  • Non-blocking reading: a key naming no field of the object is judged by nobody (import-runner.ts L721 continue); the sentence speaks only of fields that hold a list or a structured value, so it is not false, and it promises nothing new for unknown names.

④ Design judgment

packages/spec/src/api/export.zod.ts: the appended sentence holds against the code it cites and meets the card's acceptance. It tells an integrator or AI author choosing matchFields about the 400, names the field population by the spec's own arbiters, and states the upsertKey fallback, dry-run parity, per-field naming, remedy and connector-pull answer. It sits on the schema both import doors read. No must-fix, no should-fix.

Implemented-by: claude/project-thread-weezeu
Reviewed-by: session_017ihL2fi9zfJhUTubyJYb6V

Rendered by an isolated at-tier subagent (not the drafter) at CONTRACT_REVIEW_TIER, read-only against the fetched head; adopted by this seat. Companion ACCEPT: the seat's 席位核对 comment just above.

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/s tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec: ImportRequest.matchFields describe does not say a multi-value or structured field is refused as a match key

1 participant