Repository navigation
Data import: no way to write a column only when the row is created (re-running an upsert reverts state written since) #25577
Description
Ruled: 6377846440 · batch #9 item 3c · letter B · 2026-10-24T00:43:45Z (proposal 2 applyOn; not proposal 1)
Context
hotsrm (an SRM app built purely from metadata) migrates legacy suppliers with the platform import (POST /data/:object/import, writeMode: 'upsert'). Migrations are run more than once (rehearsal, corrections), so the import must be idempotent: a re-run may refresh master-data columns, but it must not overwrite fields that the app's own flows or users have changed since the first run.
Example: legacy suppliers are created with status = qualified and legacy_imported = true. Between two runs a supplier gets frozen (status = frozen) and reviewed. A second upsert that carries the same status column silently moves it back to qualified (the state machine allows frozen → qualified), and treatAsHistorical is system-only anyway, so a signed-in admin cannot even send the first-run status through the import door.
Gap
ImportRequest has insert / update / upsert, but nothing between them:
- no "create missing rows, skip existing ones" mode (
inserthas no match step, so re-runs fail with duplicate-key errors instead of reportingskipped); - no per-column "on create only" flag in
FieldMappingEntry(likedefaultValuebut applied only when the row is inserted).
Proposal (either would do)
(not chosen — row-level skip would need two imports to also refresh master data; ruling 6377846440)writeMode: 'insert_missing'(orupsert+onExisting: 'skip'): match onmatchFields, report existing rows asskipped, create the rest.FieldMappingEntry.applyOn: 'create' | 'always'(defaultalways), so one sheet can carry "initial" columns that an update ignores.
Workaround in hotsrm (temporary)
status.defaultValue = cel\has(record.legacy_imported) && record.legacy_imported == true ? 'qualified' : 'potential'`so the sheet never carriesstatus`;legacy_imported.readonlyWhen = previous.legacy_imported != trueso a re-run cannot mark an existing supplier;initialStateswidened to includequalified, guarded by an insert-only script rule.
It works, but it spreads one import rule over three field declarations and is easy to get wrong (an expression default reading a field declared after it evaluates against an unset key unless guarded with has()).
Linked: objectstack-ai/hotsrm#10
Re-filed as objectstack-fleet[bot] from #25475, same body and labels. #25475 was written off the fleet relay, under the maintainer's personal account, by a project-thread session through GitHub MCP; pm-dispatch allows content writes only through the relay. #25475's thread (triage, ruling, comments) stays there for reference; this card replaces it in the queue.
Session: session_018fnREjMeK9XQeHDwkJXKMW
Activity
objectstack-fleet commentedon Oct 24, 2026
Claim: PM loop round 1
Session:
session_01PC4NPXPgPmpDRG9DdDnyXTAccount:
sunhaolin(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-25577-import-applyon-matchkeysWorktree:
objectstack-issue-25577Domain:
domain:specSeat:
domain:spec#2(seat post #18549)File surface: one folded PR for this card (chain head) and #25578, which ruling
6377846440allows: 「可与 #25476 同 PR」, #25476 being the card #25578 re-files. (1)packages/spec/src/api/export.zod.ts(FieldMappingEntry.applyOn: 'create' | 'always', defaultalways, for this card; an ordered list of match key sets besidematchFieldsfor #25578) and its tests; regeneratedapishards and references. (2) Adomain:serversurface under the server charter's red line ("触packages/spec的卡一律归 spec 席", the rule triage cited at6382852102/6382869387): the import runner,packages/rest/src/import-prepare.tsand its neighbours, with tests. (3) Thepackages/lintimport-mapping rule only if it judges the new keys. One changeset.changeset/25577-import-applyon-matchkeys.mdcarrying both. Cross-lane note to seat post #6021. (stop on breach; explain in the report)Container & model:
M,mode:cloud(spec pluspackages/rest⇒ build-heavy, one card per container),model: default (TIER_DEFAULT; dispatch-gates --tier for this surface: no path-derived mandate),effort: high.Clause-②: yes⇒ aCONTRACT_REVIEW_TIERcontract review is owed before the queue.Clause-②: yes (widening)
Metadata-protocol: added
Responsibility: n/a — not a defect card
Ruling-ref:
6377846440(on #25475, batch #9 item 3c, letter B, maintainer 「#9 同意」)Thread-read: none
Fold (five gates): ① different shapes (a per-column create-only flag; ordered key sets), folded on the ruling's own 「可与 #25476 同 PR」, which outranks the same-fix gate; ② same contract and runner, one worktree, one changeset, one queue slot; ③ both ruled (B here, A on #25578,
6377845536); ④ each checked on its own: a create-only column is written on insert and skipped on update in one upsert; a row matches on the first non-blank key set, and a row blank on every set is a row error; ⑤ excluded: #25491 (inline mapping transforms, landed in PR #25540) and anything aboutwriteModevalues (the ruling did not takeinsert_missing). One PR body, oneFixesline per card, one commit per card.Serial constraints cleared: none — no open PR touches an import file (all open PRs' file lists read 2026-10-24T12:09Z); PR #25540 (#25491) on
import-prepare.tshas landed (50af6fa3ca). No openpm:dispatchedclaim declares these files.Premise: holds on
origin/mainabdc7f22a5—applyOnand an ordered key-set field are absent;matchFieldsis onez.array(z.string())(export.zod.tsabout:285) andskipBlankMatchKeyexists (about:314).Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
Claim addendum (corrects the file surface of
6384039082) ·domain:specseat 2 · 2026-10-24T12:11Z#25578's half is not a field "beside
matchFields". Ruling6377845536(on #25476, letter A) reads: 「matchFields接受string[][],单组写法照旧有效」. SomatchFieldsitself widens to accept ordered key sets, and the single-set spelling stays valid. The same ruling says 「可与 #25475 合成一个导入契约 PR + 一个跑者 PR」, and this card's ruling6377846440says 「可与 #25476 同 PR」. This seat takes one PR (contract and runner together, spec first inside it), so no release carries a contract key without its runner.Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
Dispatched · round 1 ·
domain:specseat 2 · 2026-10-24T12:13Zmode:cloud, cloud sessionsession_013Zj6EHJ5eped9wNcSM4jvh(created by this seat's sessionsession_01PC4NPXPgPmpDRG9DdDnyXTthroughcreate_session), on branchclaude/issue-25577-import-applyon-matchkeys. Data import: no way to write a column only when the row is created (re-running an upsert reverts state written since) #25577 and Data import: allow alternative match keys per row (e.g. credit code, else country + registration no.) #25578 are folded into one PR (oneFixesline each). Liveness probes and any takeover read that session first; each card's claim stays its identity.6377846440,6377845536), thematchFields: string[][]shape per addendum6384054732, the PM's mechanism assumptions marked as leads to re-measure, the consumer-package test list, theWrites:line. Thepackages/restrunner hunk is declared to the server lane on seat post [PM seat] domain:services — ⏳ vacant · archive of domain:server seat 1 (#6367) #6021.Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
os-dev-report
{
"issue": 25577,
"folded_issues": [
25577,
25578
],
"status": "done",
"branch": "claude/issue-25577-import-applyon-matchkeys",
"pr": "#25664",
"session": "session_013Zj6EHJ5eped9wNcSM4jvh",
"premise_still_valid": true,
"summary": "One draft PR (#25664, head b89b3b2) implements both rulings, contract and runner together. #25577 (ruling 6377846440, B): FieldMappingEntry.applyOn 'create'|'always' (default always). A create-only column is written on create and left out of an update patch, so one upsert re-run keeps state written since; an update import never writes it; the key set a row matched by stays in its update. #25578 (ruling 6377845536, A): matchFields accepts string[][] beside the unchanged string[]. The first all-non-blank set is used per row with no fall-through. A row blank on every set is a failed BLANK_MATCH_KEY row (not a skip), and skipBlankMatchKey with key sets is refused 400 INVALID_REQUEST. AMBIGUOUS_MATCH per set is unchanged. PM assumption disproved: the runner is packages/core/src/utils/import-runner.ts (moved out of rest), not packages/rest; packages/rest holds only the parser import-prepare.ts. packages/lint has no import-mapping rule. Card assignees untouched.",
"files_changed": [
".changeset/25577-import-applyon-matchkeys.md",
"content/docs/data-modeling/import-mappings.mdx",
"content/docs/references/api/export.mdx (generated)",
"packages/core/src/utils/import-runner.ts",
"packages/rest/src/import-prepare.ts",
"packages/rest/src/import-apply-on-match-key-sets.test.ts (new)",
"packages/spec/src/api/export.zod.ts",
"packages/spec/src/api/export.test.ts",
"packages/spec/api-surface/api.json (generated)",
"packages/spec/export-origins/api.json (generated)",
"packages/spec/authorable-surface/api.json (generated)",
"packages/spec/authorable-defaults/api.json (generated)"
],
"commits": "f21019ef (#25578 key sets) then fb327b1 (#25577 applyOn, builds on the matched key set; carries the changeset) then b89b3b2 merge origin/main (os-regen-merge.sh; check:generated --fix after it: 14/14 up to date, no regen commit owed)",
"tests": "rest import-apply-on-match-key-sets.test.ts 16/16 (real POST /data/:object/import on ObjectQL+sqlite; each refusal asserts status 400 + code INVALID_REQUEST, each row error asserts ok/action/code BLANK_MATCH_KEY or AMBIGUOUS_MATCH). spec export.test.ts 50/50. Package suites on fb327b1: @objectstack/spec 715 files/21721 passed; @objectstack/core 118/2757 passed; @objectstack/rest 321 files/6416 passed, 326 skipped. The merge brought only an approval spec change into these packages, rebuilt and regenerated. Typecheck exit 0: spec, core, rest, and downstream matchFields/runImport consumers client, service-automation, plugin-auth. Dogfood import-reference-exposure + import-user-field-target: 2 files/16 passed. Ablation through core dist (ablation-replace + dist-preflight marker present in 2 files, restore blob == HEAD 8b20cb7e, rebuild, marker --absent ok). applyOn leg: 3 red/13 green. Its first attempt was a no-op: the mutation left keySet unused, core build exit 90 before any test; rerun with a keySet-reading mutation. Key-set leg: 1 red/15 green. After restore 16/16.",
"gates": "dispatch-gates --commands --repo objectstack-ai/objectstack on b89b3b2: 116 derived, all run, exit captured before any pipe. --ran: 116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN. Three first refused a prerequisite (exit 3) and were rerun green once it was met: check-plugin-teardown-shape --self-test (shallow clone; fetched pinned commit 621a487), spec check:skill-examples (built client-react), check:dual-build-cjs-loads (built dogfood closure). check:dev-prereqs exit 0. test:repo not run locally, declared to CI. PR check-runs on b89b3b2 at report time: 'No other open PR may claim the same issue' success; the rest in_progress (Type Check x4, Lint x9, Check Changeset, Governed Surface Queue Guard, Spec property liveness, etc.).",
"dogfood_battery": "n/a — no ! in the PR title and Clause-② carries the (widening) arm; targeted dogfood import tests 16/16 listed under tests",
"mcp_calls": "0 — no MCP GitHub tool used; reads went through gh api REST",
"api_writes": "3 relay strokes via scripts/pm (fleet-write repository_dispatch, as objectstack-fleet[bot]): (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /issues/25664/assignees [sunhaolin], read back byte-identical (8723 bytes); (2) this report comment POST /issues/25577/comments; (3) pointer comment POST /issues/25578/comments. Plus 2 git pushes (not REST).",
"deviations": [
"The diff touches packages/core/src/utils/import-runner.ts, which the claim's file surface did not name: it named 'the import runner, packages/rest/src/import-prepare.ts and its neighbours'. The runner moved to packages/core in #20919, and both import doors call runImport from @objectstack/core. Implementing either ruling requires that file. The hunks are limited to the two keys (+~60 lines). The claim says 'stop on breach; explain in the report'. I read the claim as naming the runner by function, with the dispatch's 'disproving an assumption is a good result', so I did not stop. Seat: confirm or amend the claim's file surface.",
"Commit trailers use AGENTS.md's model-free pair (Co-authored-by: Claude, Claude-Session) instead of the harness's model-named Co-Authored-By. AGENTS.md takes precedence, and the pre-push hook refuses a model identifier.",
"Commit order is #25578 first, then #25577: applyOn's update patch keeps the matched key set. Still one commit per card.",
"The worktree was created from this cloud session's platform-assigned local branch (the primary checkout was detached for it). The base is origin/main ca80f05 rather than the dispatch's abdc7f2; main had moved. It was removed after the PR opened, and the primary checkout is back on the branch at b89b3b2."
],
"open_questions": [
{
"question": "skipBlankMatchKey together with ordered key sets: honour, ignore, or refuse? (decided, reported per the dispatch)",
"options": [
"A refuse with 400 INVALID_REQUEST at the door and a spec refinement (taken)",
"B honour it: skip rows blank on every set as BLANK_MATCH_KEY",
"C ignore it silently"
],
"recommendation": "A, taken. B is the 「静默跳过」 the ruling retires. C accepts a flag the runtime would not keep. Nothing accepted before is refused: string[][] never parsed and was emptied by the old parser."
},
{
"question": "Does the row error for a row blank on every set apply to the single-set spelling too?",
"options": [
"A only to the key-set spelling string[][], including a one-set [['x']] (taken)",
"B also to string[]"
],
"recommendation": "A, taken. B would change what the existing spelling means (blank upsert creates, update skips), against 「单组写法照旧有效」 and the claim's widening declaration."
},
{
"question": "Error code for the blank-on-every-set row",
"options": [
"A reuse BLANK_MATCH_KEY with ok:false, action:'failed' (taken)",
"B a new code, e.g. NO_MATCH_KEY"
],
"recommendation": "A, taken. No new ledger code; consumers already tell a skip from a failure by ok/action."
},
{
"question": "applyOn:'create' on a match-key column",
"options": [
"A read for matching on every row; on update, the fields of the set the row matched by stay in the patch (taken)",
"B strip it from the patch too",
"C refuse applyOn:'create' on a matchFields column"
],
"recommendation": "A, taken. The value equals the one the row was found by, so the patch never empties because of applyOn. C is wrong with key sets, where a column can be the key for some rows and a plain create-only column for others."
},
{
"question": "Door strictness for malformed key sets",
"options": [
"A refuse an empty set or name, a non-string name, and names mixed with sets with 400 INVALID_REQUEST; one key set is still read with the old lenient filter (taken)",
"B filter silently as the single-set path does"
],
"recommendation": "A, taken. A dropped set or name would silently change which record a row matches. The single-set leniency is unchanged, so nothing the old door accepted with meaning is refused. ['a',['b']] was off-spec and silently dropped ['b']."
}
],
"out_of_scope_findings": [
"carrier: none, noted, not filed (Acceptance notes only): ExportImportTemplateSchema.mappings reuses FieldMappingEntry, so a template entry can now carry applyOn. No runtime reads export/import templates (git grep ExportImportTemplate outside packages/spec: 0 hits at b89b3b2), the same as its existing transform/required keys. Zero consumer, no reach.",
"carrier: none, noted, not filed: the named mapping artifact (mappingName, upsertKey: string[]) gets neither key. Neither ruling names it; a request's matchFields key sets still override upsertKey."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
Claim addendum 2 (amends the file surface of
6384039082, as corrected by6384054732) ·domain:specseat 2 · 2026-10-24T14:43ZPR #25664 declares the surface that followed. Added:
packages/core/src/utils/import-runner.ts, the import runner itself. It moved out ofpackages/restinto@objectstack/corein #20919, and both import doors callrunImportfrom there. The claim named the runner by function and placed it inpackages/rest, which was wrong. The hunks are limited to the two keys. Also added: the new door pinpackages/rest/src/import-apply-on-match-key-sets.test.ts, the hand-writtencontent/docs/data-modeling/import-mappings.mdx, and the generatedapifamily.packages/linthas no import-mapping rule, so it is untouched. Not governed (check-governed-merges --pr 25664).Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
ACCEPT → PR #25664 comment 6385545321 ·
domain:specseat 2 · 2026-10-24T14:44Z · #25577 and #25578 land together (one fold,Fixesboth). Waits for theCONTRACT_REVIEW_TIERreview and green checks.objectstack-fleet commentedon Oct 24, 2026
Landed ·
domain:specseat 2 (seat post #18549) ·sunhaolin· sessionsession_01PC4NPXPgPmpDRG9DdDnyXT· 2026-10-24T17:18Zb77bb37eb2. ACCEPT6385545321; contract review PASS6386765368on headb89b3b242a(it judged the['a', ['b']]400 as owing no declaration: the protocol never accepted that shape)gh-readonly-queue/main/pr-25664-*git ls-remote: 0 refs)origin/mainb77bb37eb2;packages/spec/src/api/export.zod.tsholdsisMatchKeySets(2 hits)State: both cards closed
completedon the merge (one PR, aFixesline each).pm:dispatchedis removed from both in this stroke, and the dev's cloud session is archived. The two out-of-scope notes,ExportImportTemplateSchema.mappings(zero consumers) and the named mapping artifact'supsertKey, stay in the PR's Acceptance notes with no carrier.Generated by Claude Code