Skip to content

fix(core)!: an update/upsert import refuses its own rows that share a match key (DUPLICATE_MATCH_KEY) - #26250

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

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

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26133

Clause-②: no (narrowing: rows of one update / upsert import that carry the same coerced matchFields key, which used to be written one by one, now all fail with DUPLICATE_MATCH_KEY and none of them is written)
Metadata-protocol: none

What changes

One update / upsert import never writes two of its own rows for one matchFields key. Before any row is written, runImport (packages/core/src/utils/import-runner.ts) groups the rows by their key, and every row of a key carried more than once fails with ok: false, action: 'failed', code: 'DUPLICATE_MATCH_KEY', its error naming every row that carries the key and the key set. None of those rows is written; the rest of the file imports as before. The dry run answers the same rows, from the same code path.

  • The key is the coerced key the matcher queries with. The pre-pass runs coerceRow (the one coercion, ⛔ no second normalisation copy) over each row's key columns with the same coercion context the row's own pass uses, then reads the key set by keySetOf, the helper findExisting now shares. So ISO 14001 (option label) and ISO14001 (option value) are one key. A row blank on every key set, or whose key cells fail coercion, has no key and answers as before.
  • Which rows fail: every row of the key (option (a)). Recommended over (b) "first row proceeds, later ones refused" because it leaves nothing to guess: the runner never picks which spreadsheet row was right, the dry run and the write agree by construction, and re-running the fixed file is safe either way (it creates or updates the one row).
  • Update mode too; insert mode unchanged. Measured at the base: an update file with two rows of one stored key answered updated, updated for the same id, and the last row silently won, the merge the triage ruled out. insert matches nothing and still writes every row.
  • Every object, a unique index over the key included (seat ruling, server seat 2 thread, 2026-10-26T14:56Z): at the base such an object answered created + UNIQUE_VIOLATION on the write and created, created on the dry run. Keeping that path would need a runner-side copy of the engine's uniqueness rules; with the check it answers DUPLICATE_MATCH_KEY for both rows on both halves.
  • Contract (D5). DUPLICATE_MATCH_KEY lands in ERROR_CODE_LEDGER (@objectstack/core's list) with the runner as its reader, and the matchFields description in api/export.zod.ts says what it means. Generated references regenerated (gen:docs). The hand-written per-row outcome table in content/docs/data-modeling/import-mappings.mdx gains the row.

Evidence

Red first, through the real POST /data/:object/import route over a real SqlDriver (better-sqlite3 :memory:): packages/rest/src/import-upsert-in-file-duplicate-key.test.ts, whole per-row results pinned with toEqual.

case base c7aa9cde24 head
two identical rows Acme, ISO 9001, A-1 created, created (2 records) both DUPLICATE_MATCH_KEY, 0 records
ISO 14001, B-1 then ISO14001, B-1 created, created both DUPLICATE_MATCH_KEY
re-run one row of that key failed: AMBIGUOUS_MATCH updated (after the fixed file created it)
two rows of a stored key (upsert / update) updated, updated, last row wins both DUPLICATE_MATCH_KEY, record unchanged
unique-index object, two rows of one key write created + UNIQUE_VIOLATION; dry run created, created both halves DUPLICATE_MATCH_KEY
file without in-file duplicates updated, created, blank-key created ×2 identical
  • Base run of the new file: 8 failed, 1 passed (the no-duplicates case) of 9. Head: 9 of 9.
  • Ablation (rebuild + on-disk proof): if (writeMode === 'insert') return shared; replaced by an always-return through scripts/ablation-replace.mjs, ablation-dist-preflight found the marker in packages/core/dist/index.{js,cjs}, the file went 8 failed / 1 passed; restored (blob == HEAD, git diff HEAD empty), rebuilt, preflight --absent clean, 9 of 9.

Local runs at 9cb2180be (the head this PR opens with):

  • @objectstack/core test + typecheck: 121 files, 2778 tests passed. @objectstack/rest test + typecheck: 323 files, 6478 passed, 326 skipped.
  • Dependents that run the same runner: @objectstack/service-automation connector-pull suites 22 of 22; @objectstack/plugin-auth user-import suites 80 of 80.
  • Full dogfood ((narrowing) arm): 296 files passed, 3 skipped; 2523 tests passed, 18 skipped; lock VERDICT command-exit 0.
  • dispatch-gates --commands: 116 commands, all exit 0; --ran reconciles 116 derived, 116 run, 0 NOT-MEASURED. pnpm --filter @objectstack/spec check:generated: all 14 artifacts up to date. eslint (pnpm lint's command) over packages/core packages/rest packages/spec: exit 0.
  • check-governed-merges --branch: 0 of 10 paths governed, 393 changed lines; not Tier H.

Acceptance notes

  • Case-insensitive collations (not measured). The in-file key compares coerced values exactly, as sqlite and Postgres compare them in the matcher's where. Under a case-insensitive MySQL collation A-1 and a-1 are one stored key but two in-file keys, so such a pair would still both be created there. No MySQL cell was run.
  • Console wording. ui/packages/plugin-grid ImportWizard maps a few row codes to translated sentences (AMBIGUOUS_MATCH among them); DUPLICATE_MATCH_KEY falls through to the server's English error, which names the rows. Noted, not filed.
  • Who else reads the runner. The connector pull (upsertKey) and the user import (email / phone_number) run the same runImport, so a pull page or a user sheet that repeats a key now gets those rows back as DUPLICATE_MATCH_KEY too; their existing suites pass unchanged.
  • Outside the claim's listed surface: content/docs/data-modeling/import-mappings.mdx (one table row) and the four content/docs/references/api/*.mdx files gen:docs regenerated.

Generated by Claude Code

…write

Rows of one update/upsert import whose coerced match key repeats now all
fail with DUPLICATE_MATCH_KEY, naming every row that carries the key, and
none of them is written, on a dry run as on the import. Files without
in-file duplicates answer as before. The new row code lands in the
error-code ledger with the runner as its reader, and the import contract
and the per-row outcome table say so.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/spec, touching 9 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object), /:object/import (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/api/error-catalog.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-handling-server.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/wire-format.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/data-modeling/fields.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/data-modeling/import-mappings.mdx (via DUPLICATE_MATCH_KEY (literal, a string literal in ERROR_CODE_LEDGER; a string literal in runImport), /:object/import (route, bridged from symbol runImport — its route source's handler names it), /:object/import/jobs (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/kernel/contracts/data-engine.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/permissions/system-context.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it), /:object/import/jobs (route, 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 runImport — its route source's handler names it), /:object/import/jobs (route, bridged from symbol runImport — its route source's handler names it))

⛔ 6 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 runImport — its route source's handler names it))
  • content/docs/releases/v17/17-0.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/releases/v17/17-1.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/releases/v17/17-4.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/releases/v17/17-6.mdx (via runImport (symbol, a top-level function), /:object/import (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/releases/v18/18-1.mdx (via /:object/import (route, 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
  • 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 — 151 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 6862ef62abd9062b54e92a8d8f82ec1abf1320c1 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 6862ef62abd9062b54e92a8d8f82ec1abf1320c1

⚠️ 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 6862ef62abd9062b54e92a8d8f82ec1abf1320c1 → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9cb2180be17e97f065d85cd64c7816b031cd2344
Local-runs: none
Scope: packages/spec/src/api/error-code-ledger.zod.ts, packages/spec/src/api/export.zod.ts + packages/core/src/utils/import-runner.ts, packages/rest/src/import-upsert-in-file-duplicate-key.test.ts (the narrowing floor: every path outside the prose faces), and the cited code: packages/core/src/utils/import-coerce.ts (coerceRow, coerceFieldValue, CoerceContext, isBlank, matchOption, the reference branch), packages/rest/src/data-action-routes.ts (the sync import door and the async import-job worker, both runImport callers), packages/services/service-automation/src/connector-pull.ts (runImport caller), packages/plugins/plugin-auth/src/admin-import-users.ts (runImport caller), packages/spec/src/api/error-code-ledger.test.ts (the ledger's own pins), scripts/check-adr-0087-registration.mjs (the disposition categories), scripts/pm/record-recognisers.mjs (the face read off the file list). The prose faces read for the claim they make: .changeset/26133-import-duplicate-match-key.md, content/docs/data-modeling/import-mappings.mdx, content/docs/references/api/automation-api.mdx, contract.mdx, error-code-ledger.mdx, export.mdx. Read-only at the head via git show refs/seat/pr26250 and git diff against c7aa9cd; the dispatch order and the seat's own conclusions were not inputs.

Face note: the brief asked for the narrowing face; record-recognisers --paths over the PR's file list reads the schema face off packages/spec/src/api/*.zod.ts (a published schema is in the committed tree). Both map to the one tier named above, so the record stands on either reading; the Scope line carries the schema hits first and the narrowing floor after them.

① Derived judgments

Accept-set and public-surface changes the diff implies, each judged:

  1. Narrowing: an update or upsert import whose rows repeat one coerced match key now answers every such row ok: false, action: 'failed', code: 'DUPLICATE_MATCH_KEY' and writes none of them; insert is untouched (rowsSharingKey returns an empty map before any work). RIGHT, and it is the triage ruling 6417860878 verbatim: detect in-file duplicate keys before any write, refuse those rows with a row-level error naming the row numbers. The message names every row of the key and the key set.

  2. The pre-pass coerces only the key columns, through the SAME coerceRow and the SAME coerceCtx object the row's own pass now uses (the context literal was lifted out of the loop, not copied). Read against import-coerce: coerceRow iterates Object.entries(rawRow) and coerceFieldValue is per-cell with no cross-field dependency, so a key cell coerces identically whether or not its non-key siblings are present. Trim and nullValues go through isBlank, option labels and values through matchOption, lookups through the shared resolveRef, dates and numbers through the same parsers; keySetOf is the one helper findExisting now shares, so the set a row is grouped by is the set the matcher queries by. RIGHT.

  3. Coercing twice has no second side effect. createMissingOptions keeps the raw value and REPORTS it as keptOptionValues; the option is only admitted on the write that carries that context, and the pre-pass discards its kept list, so nothing is created twice. resolveRef caches only definitive hits (id or ambiguous) and never a miss, so the pre-pass warms the cache the row's pass reads; the self-object flush branch is dead during the pre-pass because pendingCreates is empty. The pre-pass swallows a thrown coercion and a cell error alike by dropping the key (the row's own pass reports them), so no row gains a second error channel. RIGHT.

  4. Key equality by JSON.stringify([keySets.indexOf(keySet), values]): coerced numbers are numbers ("1" and 1 agree), options are the option value, lookups are the target id string, dates are the parser's one output per cell so two equal cells serialise alike, multi-value cells are arrays compared in order exactly as the matcher's where would pass them. The index of the key set is in the key, so a row matched by set 0 and a row matched by set 1 never collide, which is what the matcher does (it queries one set only). keySetOf requires every member non-blank, so no undefined reaches the serialiser. RIGHT; the exact-compare boundary under a case-insensitive MySQL collation is named in the PR's acceptance notes and is the matcher's own boundary, not a new one.

  5. Dry run: the shared check sits before the dryRun branch in the row loop, so the preview and the write answer the same rows from the same code path; the unique-index object's preview, which used to say created, created while the write said created plus UNIQUE_VIOLATION, now agrees on both halves. RIGHT, and pinned by the test file (dry results equal real results minus ids).

  6. Every runImport caller gets the one behaviour: the sync door and the async job in data-action-routes spread prepared into the same call; the connector pull (upsert on upsertKey) and the user import (email / phone_number, skipBlankMatchKey: true) run the same runner. ImportRowResult.code is an open string, so no caller's type narrows; the UI wording fallthrough is noted below. RIGHT.

  7. Progress and yield accounting: the pre-pass yields to the event loop every progressEvery rows, so a synchronous driver cannot starve the cancel route during it; it reports no onProgress snapshot (nothing is processed yet) and processed, total, errors in the row loop are unchanged, so an async job's counters are still truthful. A refused row counts in errCount exactly as an AMBIGUOUS_MATCH row does. shouldCancel is not polled during the pre-pass, so a cancel lands at the first checkpoint of the row loop instead; a delay, never a lost cancel. RIGHT, non-blocking.

  8. The spec: DUPLICATE_MATCH_KEY joins ERROR_CODE_LEDGER['@objectstack/core'] beside AMBIGUOUS_MATCH and BLANK_MATCH_KEY, the runner being its first reader (D5); the matchFields description in export.zod.ts says what it means; the four references under content/docs/references/api are the gen:docs projection (the +344 more to +345 more deltas and the one enum row each). No sharded artifact carries the ledger's members (grep of api-surface, json-schema.manifest and authorable-surface for AMBIGUOUS_MATCH finds none), so the regenerated set is complete. RIGHT.

One boundary the claim text does not name (non-blocking, no local run): a key column that is a lookup to THIS object, pointing at a row the same file creates earlier, resolves reference_not_found in the pre-pass (nothing is buffered yet) and so has no key there; in the row loop the self-object flush resolves it. Two such rows sharing a key therefore still answer created then updated at this head. It is the pre-existing in-file self-reference corner, not a regression, and outside the card's repro; it belongs in the PR's acceptance notes or a follow-up card, not in this landing.

Check-runs on the head, read 2026-10-26T17:02Z: success on Governed Surface Queue Guard, Check Changeset, Check PR Size, Lint ESLint, Lint CI release and engine gates, Lint repo and tooling gates, Lint docs and code guards, Lint scoped (tooling self-tests, slot lookup and corpus, query-options erasure), Lint PM dispatch-gates, Type Check source gates, Build Docs, Build UI Deps, Dogfood Regression Gate, Spec property liveness, Flag docs, Check Documentation Links, the card-claims-branch, part-of and single-writer guards, Auto Label, filter. Still pending at that read: Lint and Repo Gates (queued aggregate), Test Core 1 through 8, Build Core, Type Check workspace, Type Check debt ledger, Type Check consumer gates, Type Check UI, Build and E2E UI. Skipped by path filter: Dogfood Verify CLI and shards, Temporal Conformance, Lint UI, Test UI, the opt-in tarball and 18.x harness. Nothing red. The queue lands only on every required context green, so the pending set is the queue's to wait for, not this record's to pre-judge.

② Semver level

Changeset .changeset/26133-import-duplicate-match-key.md: @objectstack/core: patch, @objectstack/spec: patch. The diff moves core (the runner) and spec (one ledger member plus contract text), and no other published package. Metadata-protocol: none is right: an error code is not a key, value or type a metadata author writes, and ImportRequestSchema accepts exactly what it accepted. The rule for none is patch, and a non-protocol narrowing ships patch while keeping its BREAKING banner and ADR-0087 disposition; both are present. Clause-②: no (narrowing: ...) is line-initial in the PR body and the changeset, the one closed arm, spelled identically in both. The BREAKING paragraph states the before and after and the caller's move (keep one row per key, import again). The ADR-0087 marker is exactly one, not-required (no-migration-prescription); its body prescribes no consumer rewrite (there is no FROM to TO, no tombstone, no key change), so the category's own refusal does not bite, and Check Changeset is green on this head. The new ledger member is a widening of the closed ApiErrorSchema.code union, published from spec; that is the ordinary D5 registration of a code with a reader and takes no changeset level beyond patch. Level: RIGHT.

③ Boundary flags

  • Dev flag: content/docs/data-modeling/import-mappings.mdx (one row in the hand-written per-row outcome table) outside the claim's listed surface. IN BOUNDS: the claim's prohibition reads No other packages/spec/** edit, which this path is not; the table lists every per-row code and would otherwise advertise a vocabulary narrower than the runtime delivers. The tree's meta.json needs no change (no page added).
  • Dev flag: the four content/docs/references/api/*.mdx files gen:docs regenerated. IN BOUNDS and mandatory: AGENTS.md's spec-artifact rule makes a .describe() change regenerate gen:schema && gen:docs before push, and check:docs would red without them; Type Check source gates is green on this head.
  • open_questions: [] in the dev report: nothing to answer.
  • out_of_scope_findings (noted, not filed): the case-insensitive MySQL collation pair is unmeasured and reads as the matcher's own exact-compare boundary; the Console's ImportWizard wording falls through to the server's English error, which names the rows. Both are acceptance notes, not reproducible defects; noting is the right disposition under Prime Directive 10.
  • The unique-index widening: the dev attributes refusing duplicates on unique-index objects too to a seat ruling in the server seat 2 thread at 2026-10-26T14:56Z. That ruling is not among the card's three comments (triage, claim, dev report); the card itself carries only the triage ruling 6417860878, which already says detect before any write and refuse those rows, with no object-class exception, so the behaviour stands on the card's own ruling. ESCALATED as a record hygiene point: a ruling the dev cites should be on the card for the next reader; no change to the diff.
  • Face: the committed tree touches two published schema files, so the schema face also applies; same tier, no second record owed.
  • The in-file self-reference corner named under ① is the one open boundary; recommended as an acceptance note or a follow-up card, not a blocker.

Implemented-by: claude/project-thread-bc6mw6
Reviewed-by: session_0186vfjHHMsRXC9EUT9hTFMo

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

objectstack-fleet Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-10-26T17:28Z · head 9cb2180be17e97f065d85cd64c7816b031cd2344 · #26133

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape: a draft against main with Fixes #26133. Clause-②: no (narrowing …) and Metadata-protocol: none are line-initial and identical in the PR body and the changeset. One changeset (patch on @objectstack/core and @objectstack/spec, BREAKING banner, one ADR-0087 marker not-required (no-migration-prescription)). check-governed-merges --pr 26250: NOT governed (0 of 10 paths), 393 changed lines.
  2. Contract review: 6422851087, CONTRACT_REVIEW_TIER, PASS on this head. The duplicate pre-pass uses the same coerceRow and the same coercion context as the row's own pass, coerces twice with no second side effect, and is asked before the dry-run branch, so a dry run answers what the import answers on all four runImport callers. The new ledger member DUPLICATE_MATCH_KEY and the matchFields contract text are the only published-schema change, and the four regenerated reference pages are gen:docs output. The unique-index ruling the dev cites is now on the card (6422286064), resting on triage 6417860878.
  3. Gates: at this stamp the head's 48 check-runs read 41 success and 7 skipped (UI, dogfood matrix placeholders, Temporal and opt-in harnesses), with none failing or in progress; Test Core 1–8, Build Core, every Type Check · and Lint · lane, Spec property liveness, Check Changeset and the Governed Surface Queue Guard are green. The dev reports 116 of 116 derived gate families exit 0, reconciled with --ran, and the full dogfood battery green.
  4. Accepted deviations: the two files outside the claim's listed surface (import-mappings.mdx, one outcome-table row; the four gen:docs reference pages) are in bounds: the claim's only prohibition was any packages/spec/** edit beyond its two named files, and gen:docs regeneration is required once the ledger and the contract text change. [Edit: corrected the wording of this reason.] One corner the review named and the seat accepts as is: a key column that is a lookup to the imported object, naming a row the same file creates earlier, has no key in the pre-pass (nothing is created yet), so two such rows still answer created then updated. That behaviour predates this PR and is outside the card's repro.
  5. Out of scope: none filed.
  6. Landing: this seat runs pr_ready and then automerge_enable through the relay, confirms MERGED on both readings, and records the landing on /import upsert: two rows of one file with the same matchFields key both insert, so the file writes a duplicate and every later run answers AMBIGUOUS_MATCH #26133, which the merge closes.

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

Projects

None yet

2 participants