Skip to content

feat(spec,core,rest): import column applyOn create-only, and matchFields ordered key sets - #25664

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-25577-import-applyon-matchkeys
Oct 24, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-25577-import-applyon-matchkeys

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #25577
Fixes #25578

Clause-②: yes (widening)
Metadata-protocol: added
Ruling-ref: 6377846440
Ruling-ref: 6377845536

One import-contract PR for two ruled cards, contract and runner together (spec first in each commit), so no release carries either key without the runner that honours it. One commit per card: f21019ef (key sets, the second card) lands first because applyOn's update patch keeps the key set the row was matched by; fb327b11 (applyOn, the first card) builds on it; b89b3b24 merges origin/main (no regeneration was owed after it: check:generated 14/14 up to date).

Rulings implemented

  • First card, ruling 6377846440, letter B, verbatim: 「B = FieldMappingEntry.applyOn: 'create' | 'always'(默认 always):标 create 的列只在新建行时写,已有行不动;一次 upsert 即幂等(主数据列刷新,状态列不冲回)。」 No new writeMode value.
  • Second card, ruling 6377845536, letter A, verbatim: 「A = 匹配键接受有序多组(matchFields 接受 string[][],单组写法照旧有效),每行用第一组非空的键;哪组都空就报行错误,不再静默跳过;AMBIGUOUS_MATCH 规则不变。」

What changed

packages/spec (src/api/export.zod.ts)

  • FieldMappingEntry.applyOn: z.enum(['create', 'always']).default('always').
  • ImportRequest.matchFields is string[] (unchanged) or string[][] (non-empty list of non-empty sets).
  • A refinement refuses skipBlankMatchKey: true together with key sets (path skipBlankMatchKey), mirroring the door.
  • New export isMatchKeySets(matchFields), the one predicate the spec refinement, the REST door and the runner share.
  • Regenerated: api-surface/, export-origins/, authorable-surface/, authorable-defaults/, content/docs/references/api/export.mdx.

packages/core (src/utils/import-runner.ts, the runner both import doors call)

  • RunImportOptions.matchFields: string[] | string[][], plus createOnlyFields?: string[].
  • Each row uses the first key set whose fields are all non-blank on it, and only that set: a miss on it is none (upsert creates), never a fall-through to the next set.
  • Key sets: a row blank on every set is { ok: false, action: 'failed', code: 'BLANK_MATCH_KEY' }, on the dry run and the commit alike. One key set keeps its own blank handling exactly (upsert creates, update and skipBlankMatchKey skip with BLANK_MATCH_KEY).
  • An update (and its dry-run preview, and its undo snapshot) writes the row minus its create-only columns, keeping the fields of the key set the row was matched by.

packages/rest (src/import-prepare.ts)

  • Reads matchFields in both spellings. One key set is read exactly as before (non-string names dropped). Key sets are read strictly: an empty set or name, a non-string name, or names mixed with sets refuse with 400 INVALID_REQUEST.
  • skipBlankMatchKey: true with key sets refuses with 400 INVALID_REQUEST.
  • Collects the targets of inline entries with applyOn: 'create' into createOnlyFields; an applyOn outside create / always refuses with 400 INVALID_REQUEST.

Docs: content/docs/data-modeling/import-mappings.mdx gains the two sections, the new refusals and the failed BLANK_MATCH_KEY row.

Decisions taken within the rulings

  1. skipBlankMatchKey with key sets is refused (400), not honoured. Honouring it would skip a row blank on every set, which is the 「静默跳过」 the ruling retires; ignoring it would accept a flag the runtime does not keep. One key set keeps skipBlankMatchKey as today.
  2. The blank-row rule follows the spelling. [["credit_code"]] (key-set form, one set) reports a blank row as a failed BLANK_MATCH_KEY; ["credit_code"] keeps today's create/skip. Applying the row error to the single-set spelling would change its meaning, which 「单组写法照旧有效」 rules out.
  3. The row error reuses BLANK_MATCH_KEY with ok: false / action: 'failed', so no new error code enters the ledger; the skipped and the failed rows are told apart by ok / action.
  4. applyOn: 'create' on a match-key column: the column is read for matching on every row; on update, the fields of the key set the row was matched by stay in the patch (the value equals the one the row was found by), so the update patch is never empty because of applyOn. With key sets, a create-only column that is not in the row's matched set is left out of that row's update.

Tests

All on b89b3b24 (the final head) unless noted.

  • packages/rest/src/import-apply-on-match-key-sets.test.ts (new): 16/16, through the real POST /data/:object/import route on ObjectQL + sqlite. It covers: an upsert run twice keeps a create-only column and refreshes an always column (and creates a new row with it); update mode never writes it, and the dry run agrees; a create-only match-key column still matches; the always / absent control; applyOn: 'insert' gives 400 INVALID_REQUEST. For key sets: a mixed sheet matches set 1 and set 2 in one import and is idempotent on re-run; no fall-through from a non-blank first set; a row blank on every set is a failed BLANK_MATCH_KEY on the dry run and the commit; AMBIGUOUS_MATCH per set; skipBlankMatchKey with key sets gives 400 INVALID_REQUEST; four malformed key-set shapes give 400 INVALID_REQUEST; and a control showing one key set's blank handling unchanged. A last test exercises both keys in one sheet.
  • packages/spec/src/api/export.test.ts: 50/50 (applyOn default, accept and refuse; key sets accept and refuse; skipBlankMatchKey refinement; typed SDK request).
  • Package suites: @objectstack/spec 715 files / 21721 tests passed; @objectstack/core 118 / 2757 passed; @objectstack/rest 321 files / 6416 passed, 326 skipped (taken on fb327b11; the merge brought no change to these three packages' sources except the spec approval schema, rebuilt and regenerated).
  • Typecheck: @objectstack/spec, @objectstack/core, @objectstack/rest, and the other matchFields / runImport consumers @objectstack/client, @objectstack/service-automation, @objectstack/plugin-auth (downstream of the changed contract): all exit 0.
  • Dogfood import tests (import-reference-exposure, import-user-field-target): 2 files / 16 tests passed.
  • packages/lint: has no import-mapping rule (no file judges matchFields or mapping entries), so it is not touched.

Ablation (the doors read the runner from @objectstack/core's dist/; each leg: scripts/ablation-replace.mjs mutation, core rebuild, ablation-dist-preflight.mjs marker present in 2 built files, run, restore with blob equal to HEAD, rebuild, marker absent):

  • applyOn: updatePatch keeps every column. 3 tests red (upsert run twice; update mode; both keys in one sheet), 13 green. The first attempt at this leg did not run: the mutation left keySet unused, so the core build failed (exit 90) before any test; it was rerun with a mutation that still reads keySet.
  • Key sets: the blank-on-every-set branch disabled. 1 test red (row error on dry run and commit), 15 green.
  • After restore: 16/16 green.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on b89b3b24 derived 116 families, all run locally, each exit captured before any pipe. Reconciliation: --ran reports 116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN. Three families first refused a prerequisite (exit 3) and were rerun green once it was met: check-plugin-teardown-shape --self-test (shallow clone; the pinned fixture commit was fetched), spec check:skill-examples (built client-react), check:dual-build-cjs-loads (built the dogfood closure). check:dev-prereqs (local-only) exit 0. test:repo was not run locally; it belongs to CI.

Acceptance notes

  • ExportImportTemplateSchema.mappings also uses FieldMappingEntry, so a template entry can now carry applyOn. No runtime code reads export/import templates (git grep ExportImportTemplate outside packages/spec finds nothing), so this is the same as its existing transform / required keys. Noted, not filed (no consumer).
  • The named mapping artifact (mappingName) has its own fieldMapping pipeline and an upsertKey: string[]. Neither ruling names it, so it gets neither key: applyOn is an inline-entry key, and upsertKey stays one key set. A request's matchFields key sets still override an artifact's upsertKey as before.
  • 「hotsrm 发版后拆掉三处字段声明绕行」 / 「拆掉两遍导入的绕行」 is hotsrm-side work, not part of this PR.

Generated by Claude Code

`matchFields` takes one key set (`string[]`, unchanged) or ordered key
sets (`string[][]`). Each row is matched by the first set whose fields
are all non-blank on it, and only by that set; a row blank on every set
fails with BLANK_MATCH_KEY instead of being created or skipped.
`skipBlankMatchKey` with key sets, and malformed key sets, are refused
with 400 INVALID_REQUEST. AMBIGUOUS_MATCH per set is unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Zj6EHJ5eped9wNcSM4jvh
…ly when its row is created

`FieldMappingEntry.applyOn: 'create' | 'always'` (default `always`). A
create-only column is written when the import creates the row and left
out of the patch when an upsert updates an existing one (an update import
never writes it), so one upsert re-run refreshes master data without
reverting state written since. The key set a row was matched by stays in
its update. An applyOn value outside create / always is refused with
400 INVALID_REQUEST.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/core, @objectstack/rest, @objectstack/spec, touching 20 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/authorable-defaults/api.json, packages/spec/authorable-surface/api.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/client-sdk.mdx (via /:object/import (route, bridged from symbol prepareImportRequest — its route source's handler names it; bridged from symbol runImport — its route source's handler names it))
  • content/docs/api/error-catalog.mdx (via INVALID_REQUEST (literal, a string literal in prepareImportRequest; a string literal in readMatchFieldsBody))
  • content/docs/api/metadata-api.mdx (via INVALID_REQUEST (literal, a string literal in prepareImportRequest; a string literal in readMatchFieldsBody))
  • content/docs/api/wire-format.mdx (via /:object/import (route, bridged from symbol prepareImportRequest — its route source's handler names it; bridged from symbol runImport — its route source's handler names it))
  • content/docs/automation/webhooks.mdx (via INVALID_REQUEST (literal, a string literal in prepareImportRequest; a string literal in readMatchFieldsBody))
  • content/docs/data-modeling/fields.mdx (via /:object/import (route, bridged from symbol prepareImportRequest — its route source's handler names it; bridged from symbol runImport — its route source's handler names it))
  • content/docs/data-modeling/import-mappings.mdx (via matchFields (symbol, a field of interface PreparedImport; a field of interface RunImportOptions), BLANK_MATCH_KEY (literal, a string literal in runImport), INVALID_REQUEST (literal, a string literal in prepareImportRequest; a string literal in readMatchFieldsBody), credit_code (literal, a string literal in readMatchFieldsBody), registration_no (literal, a string literal in readMatchFieldsBody), skipBlankMatchKey (literal, a string literal in ImportRequestSchema), /:object/import (route, bridged from symbol prepareImportRequest — its route source's handler names it; bridged from symbol runImport — its route source's handler names it), /:object/import/jobs (route, bridged from symbol prepareImportRequest — its route source's handler names it; bridged from symbol runImport — its route source's handler names it))
  • content/docs/permissions/authentication.mdx (via INVALID_REQUEST (literal, a string literal in prepareImportRequest; a string literal in readMatchFieldsBody))
  • content/docs/permissions/sso.mdx (via INVALID_REQUEST (literal, a string literal in prepareImportRequest; a string literal in readMatchFieldsBody))
  • content/docs/permissions/system-context.mdx (via /:object/import (route, bridged from symbol prepareImportRequest — its route source's handler names it; bridged from symbol runImport — its route source's handler names it), /:object/import/jobs (route, bridged from symbol prepareImportRequest — its route source's handler names it; bridged from symbol runImport — its route source's handler names it))
  • content/docs/protocol/objectql/state-machine.mdx (via /:object/import (route, bridged from symbol prepareImportRequest — its route source's handler names it; bridged from symbol runImport — its route source's handler names it), /:object/import/jobs (route, bridged from symbol prepareImportRequest — its route source's handler names it; bridged from symbol runImport — its route source's handler names it))
  • content/docs/ui/forms.mdx (via INVALID_REQUEST (literal, a string literal in prepareImportRequest; a string literal in readMatchFieldsBody))

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

  • content/docs/releases/v12.mdx (via /:object/import (route, bridged from symbol prepareImportRequest — its route source's handler names it; bridged from symbol runImport — its route source's handler names it))
  • content/docs/releases/v17/17-0.mdx (via INVALID_REQUEST (literal, a string literal in prepareImportRequest; a string literal in readMatchFieldsBody))
  • content/docs/releases/v17/17-6.mdx (via runImport (symbol, a top-level function), INVALID_REQUEST (literal, a string literal in prepareImportRequest; a string literal in readMatchFieldsBody), /:object/import (route, bridged from symbol prepareImportRequest — its route source's handler names it; bridged from symbol runImport — its route source's handler names it))
  • content/docs/releases/v18/18-0.mdx (via INVALID_REQUEST (literal, a string literal in prepareImportRequest; a string literal in readMatchFieldsBody))
  • content/docs/releases/v18/18-1.mdx (via INVALID_REQUEST (literal, a string literal in prepareImportRequest; a string literal in readMatchFieldsBody), /:object/import (route, bridged from symbol prepareImportRequest — its route source's handler names it; bridged from symbol runImport — its route source's handler names it))

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
  • 4 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/authorable-defaults/api.json, packages/spec/authorable-surface/api.json, …) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 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 — 152 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 baae6ed14e90e357b6e437e62aed01f0f1be9649 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 baae6ed14e90e357b6e437e62aed01f0f1be9649 → 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

席位核对

ACCEPT · #25577 + #25578 (folded) · domain:spec seat 2 (seat post #18549) · sunhaolin · session session_01PC4NPXPgPmpDRG9DdDnyXT · 2026-10-24T14:42Z · head b89b3b242a · report 6385017470 (on #25577) · seat review at TIER_DEFAULT. Both clause-② limbs hit (path: packages/spec/src/api/export.zod.ts; declaration: Clause-②: yes), so a same-shape ## Contract review PASS at CONTRACT_REVIEW_TIER on the landing head is owed. needs:contract-review is added in this stroke. The seat's own tier is behind its quota wall (HTTP 429, re-probed at this record).

  1. Shape. Draft, base main. The first lines are Fixes #25577 and Fixes #25578 (one fold, one PR), with no other closing keyword. Clause-②: yes (widening), Metadata-protocol: added, and two Ruling-ref: lines (6377846440, 6377845536) are line-initial. The changeset is minor on spec, core and rest, with both declarations. 12 files, +627/−33. Not governed (check-governed-merges --pr 25664). Two commits, one per card, then one origin/main merge through os-regen-merge.sh (check:generated 14/14 after it).
  2. The rulings, measured on the diff.
  3. Changeset and docs, checked line by line against the diff.
    • The changeset's two bullets match the door and the runner: the applyOn value refusal, "the key a row was found by stays in that row's update", the BLANK_MATCH_KEY failure, and the two 400 refusals (skipBlankMatchKey with key sets; malformed key sets).
    • isMatchKeySets and RunImportOptions.createOnlyFields are the new exports it names.
    • content/docs/data-modeling/import-mappings.mdx (+43/−3: two new subsections, the 400 row and the two BLANK_MATCH_KEY rows) and the regenerated references/api/export.mdx restate the same rules, read line by line. No content/docs/releases/**.
  4. Gates. Check-runs on b89b3b242a at this record: 42, all completed, none failed (ci-failure --pr 25664 GREEN). Local, per the report:
    • 116/116 derived, --ran DERIVED zero.
    • spec 715 files / 21721 tests; core 118 / 2757; rest 321 / 6416.
    • The new door pin is 16/16 on a real POST /data/:object/import over ObjectQL + sqlite, each refusal asserting status and code.
    • Ablations through core dist: applyOn 3 red, key sets 1 red, then restored. The first applyOn attempt was a no-op, declared and redone.
    • test:repo was not run locally. CI's Test Core carries it and is green.
  5. Deviations accepted.
  6. Open questions and boundary flags (all five decided in the PR; the contract review answers them).
    • skipBlankMatchKey with key sets refuses 400, rather than skip or ignore. Inside ruling A's retirement of 「静默跳过」, on a spelling no request could use before.
    • The row error for a row blank on every set applies to the key-set spelling only. string[] keeps its meaning, per 「单组写法照旧有效」.
    • It reuses BLANK_MATCH_KEY with ok: false; no new code.
    • applyOn: 'create' on a match-key column is read for matching and kept in the matched set's patch.
    • For the contract review, a door narrowing: an array mixing names with sets (['a', ['b']]) was accepted before, with ['b'] silently dropped. It is now 400 INVALID_REQUEST. The spec never accepted that shape, but the HTTP door did. The review judges whether this needs declaring.
    • Out of scope, Acceptance notes: ExportImportTemplateSchema.mappings can now carry applyOn, which no runtime reads (zero consumers, as for its transform / required). The named mapping artifact (upsertKey) gets neither key; a request's key sets still override it.
  7. Landing. Not governed, 660 changed lines. Once a CONTRACT_REVIEW_TIER PASS is on record and every check on the then-current head is green, the seat sends pr_ready + automerge_enable through the relay. The merge closes both cards.

Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b89b3b242adb7d8a71dc7a0bc3aa6a8f75f8c283
Local-runs: none
Scope: packages/spec/src/api/export.zod.ts + packages/spec/src/api/export.test.ts, packages/spec/api-surface/api.json, packages/spec/authorable-defaults/api.json, packages/spec/authorable-surface/api.json, packages/spec/export-origins/api.json, packages/core/src/utils/import-runner.ts, packages/rest/src/import-prepare.ts, packages/rest/src/import-apply-on-match-key-sets.test.ts (the whole non-prose diff; content/docs/** and the changeset are prose faces, read for ② only)

Face: published schema (packages/spec/src/api/export.zod.ts), plus Clause-②: yes (widening). Read: the net diff, cards #25577 and #25578 with rulings 6377846440 (B) and 6377845536 (A), the PR body, the check-runs on b89b3b24.

① Derived judgments

  • FieldMappingEntry.applyOn: z.enum(['create','always']).default('always') — additive optional key with a default equal to today's behaviour; the parsed type gains a required member (ADR-0122), regenerated in api-surface, authorable-surface, authorable-defaults, export-origins. Accept-set: widened only. Ruling B implemented to the letter (no new writeMode value).
  • ImportRequest.matchFields: string[] | string[][] — the union's first member is the old schema unchanged, so every previously valid request parses to the same value; [] still parses through the first member. The second member requires non-empty sets and a non-empty list. Ruling A implemented: ordered sets, first all-non-blank set per row, no fall-through, AMBIGUOUS_MATCH per set unchanged.
  • New refinement !(skipBlankMatchKey && isMatchKeySets(matchFields)) — refuses only the new spelling, so no previously valid request is refused. Right: honouring it would reinstate the 「静默跳过」 ruling A retires; ignoring it would accept a flag the runtime does not keep.
  • New export isMatchKeySets from @objectstack/spec/api (re-exported via export * from './export.zod'): one predicate shared by schema, door and runner. Additive.
  • Runner (@objectstack/core RunImportOptions): matchFields type widened, createOnlyFields? added. Single-set behaviour preserved (find over one set reproduces the old blank sentinel exactly; skipBlankMatchKey and update skip as before). Key-set form: a row blank on every set is ok: false, action: 'failed', code: 'BLANK_MATCH_KEY' on dry run and commit; the old code is reused and the skipped/failed rows are told apart by ok/action. Update patch = row minus create-only columns, keeping the matched key set; dry-run preview and undo snapshot use the same patch. Right.
  • Door (import-prepare.ts): one set read exactly as before; key sets read strictly; applyOn outside the enum, skipBlankMatchKey with sets, and malformed sets refuse 400 INVALID_REQUEST. Right.
  • ExportImportTemplateSchema.mappings inherits applyOn: additive, no runtime consumer (same standing as its transform/required). The named mapping artifact (upsertKey) gets neither key; a request's key sets still override it. Within both rulings.

② Semver level

minor on @objectstack/spec, @objectstack/core, @objectstack/rest; Clause-②: yes (widening) and Metadata-protocol: added line-initial in body and changeset; no !, no BREAKING section — correct for an additive contract change. Both Ruling-ref: lines present. Check-runs on b89b3b24: CI, Lint & Type Check, Spec Liveness Check, Governed Surface Guard, Docs Drift Check, Check Links, the four claim guards, PR Automation all success; Pack Smoke and Upgrade Harness skipped (opt-in). No red on this head.

③ Boundary flags

  • The seat's flag for this review: an array mixing names with sets (['a', ['b']]) was accepted by the HTTP door before (the nested array silently dropped) and is now 400 INVALID_REQUEST. Judged: no declaration owed. The published contract (z.array(z.string())) never accepted that shape; the door's prior leniency was off-contract behaviour, and refusing it loudly is the AGENTS.md route rule (absence and malformation are loud). The accept-set of the contract is unchanged for that input. A one-line mention in the changeset would be courteous, not required; not blocking.
  • skipBlankMatchKey + key sets → 400 rather than honoured or ignored: answered above, inside ruling A.
  • Blank-row error follows the spelling ([['x']] errors, ['x'] keeps create/skip): answered; required by 「单组写法照旧有效」.
  • BLANK_MATCH_KEY reused with ok: false: answered; no new ledger code.
  • applyOn: 'create' on a match-key column stays in the matched set's patch: answered; the update patch is never empty because of applyOn.
  • No open_questions entry on either card. 「拆掉绕行」 lines are hotsrm-side, out of scope.

Implemented-by: session_013Zj6EHJ5eped9wNcSM4jvh
Reviewed-by: session_01Bf4vhia9fHNXpqC1PCfxie

VERDICT: PASS

needs:contract-review removed in this stroke. Landing (pr_ready + automerge_enable) stays with the domain:spec seat; the record governs this head only.


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

3 participants