Repository navigation
feat(spec,rest,lint): an import mapping target may name a declared part of a compound field (mailing_address.street) - #20246
Conversation
…rt of a compound field A target may name field.part when the field's stored value schema is a closed object of optional strings (address); the import door assembles the parts one row maps into one value before the engine sees the row. An unknown part, a dotted path on a field with no parts, and a field written both whole and by part stay refused at validate, the dry run and the commit, naming the legal parts. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
…eference for the part target Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f0403634169759059d809c2bd1bf41c1e5e89fc3 && git checkout f0403634169759059d809c2bd1bf41c1e5e89fc3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fdb26698f975511a01a01f53d506058977db7cef e652a77395cfd4a2ad83724f84a68fbaa08ae2ee && git checkout -B drift-repro fdb26698f975511a01a01f53d506058977db7cef && git merge --no-ff e652a77395cfd4a2ad83724f84a68fbaa08ae2ee
node scripts/docs-audit/affected-docs.mjs --json fdb26698f975511a01a01f53d506058977db7cef
|
Contract reviewServed-tier: 41/41 ① Derived judgmentsBLOCKING (5/1). The collision refusal lists no legal parts, and three texts say it does. Ruling item 3: "a refusal names the field and lists the legal parts."
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL |
… parts too Ruling item 3 on #20149 says a refusal names the field and lists the legal parts. The rest door emitted the parts sentence only when an unknown target was present, and the lint collision hint carried no part list; both now list the object's compound fields and their parts on every refusal, and the collision pins assert it. The mapping.zod docblock now names the reader of the part set. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 64/64 ① Derived judgments
CI at head: 35 check runs, 33 success, 2 skipped (Console Pin Gate, Packed-tarball smoke), 0 failed. ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…ckages readers (objectstack-ai#20229) Fixes objectstack-ai#20206 Clause-②: yes (narrowing) Both facts are true and both are read. `yes`: `ERROR_CODE_LEDGER['@objectstack/lint']` (`@objectstack/spec`, published) is a new per-package face, present where it was absent before (rework round 1, fixing a red `check:error-code-provenance`). `narrowing`: `packages/lint` is published, and `os lint` now refuses a `stack.packages` shape it used to accept silently — the spec `packages` array declaration itself does not move; only this reader now honours it (`os validate` and `os build` already refuse a malformed `packages` earlier, at `ObjectStackDefinitionSchema.safeParse`, before ever reaching `packages/lint`'s rules — this PR does not change that door). Two changesets carry the two facts separately: `.changeset/20206-lint-packages-non-array-refused.md` (`@objectstack/lint: minor`, `Clause-②: no (narrowing)`, the ADR-0087 disposition) and `.changeset/20206-lint-error-code-provenance-row.md` (`@objectstack/spec: minor`, `Clause-②: yes`). ## What changed Ruling A on objectstack-ai#15293 (comment 5634034754): a present `packages` that is not an array (`{}`, `0`, `'x'`, a keyed object) is malformed, not absent, and every reader must refuse it. Four `packages/lint` readers fell through `recordsOf(stack.packages)` to `[]` instead: - `validate-object-references.ts:165` (`artifactProvidedObjectNames`) - `validate-translation-references.ts:753, 848, 921` (`contributedNavItemsByApp`, `objectExtensionsByTarget`, `artifactProvidedRecords`) They now share one small reader, `packagesOf(stack)` (`object-graph.ts`), which: - returns `[]` for an absent `packages` (`undefined` ONLY — see the `null` leg below); - reads a well-formed array exactly as `recordsOf` did (junk entries dropped, unchanged); - throws `INVALID_ARTIFACT_PACKAGES` (ADR-0112, `status: 422`) for anything else present. `recordsOf` itself is untouched: it stays the shared map-or-array reader `objects`/`sections`/`tabs` need, where a keyed map is legitimate. `packages` never has a map form, so this is a second, narrower reader rather than a branch on the first one. ## A fifth site, found on re-reading `origin/main` The card's site census was taken at `origin/main` `1c8b320`. This worktree forked from a later `origin/main` that already carries objectstack-ai#20208 (merged), which added a fifth copy of the identical `recordsOf(stack.packages)` pattern: `validate-mapping-target-fields.ts:95` (`extensionFieldsByTarget`). Fixed here under the in-place-fix exemption — same defect class as this card, a mechanical fix with the form already pinned by the other four, the file held by no other claim (objectstack-ai#20208 is merged), same gate family, no new verification surface. This report amends the claim's declared file surface to include `validate-mapping-target-fields.ts` and its test. ## Rework round 1 — the `null` leg (ruling A on objectstack-ai#19926, `5805260775`) `null` is malformed, everywhere. This card originally put `packages: null` out of scope with a pointer to objectstack-ai#19926, but objectstack-ai#19926's own claim fenced `packages/lint` out as its surface — the lint leg had no owner. Per the seat's review comment on objectstack-ai#20206 (5855890525), that leg moves here as the execution of an existing ruling, not a new decision: - `packagesOf` now treats only `undefined` as absent; `null` falls to the same `INVALID_ARTIFACT_PACKAGES` refusal as `{}` / `0` / `'x'` / a keyed object. - The refusal message names `null` as itself (`` `packages` of type null ``) rather than `typeof null`'s `'object'`, which would name a `{}` the author never wrote — the naming objectstack-ai#19926 (PR objectstack-ai#20228) gives `resolveArtifactPackageOrder` once it lands, adopted early here. - Every `null` pin flipped from a silence control to a refusal assertion: `packagesOf` directly (`object-graph.test.ts`), and each of the three public functions its five readers sit behind (`validateObjectReferences`, `validateTranslationReferences`, `validateMappingTargetFields`). `undefined` (absent) and a well-formed array stay green controls. - Ablated: `|| declared === null` restored into the absent branch via `scripts/ablation-replace.mjs` — all four `null` pins (one per test file) went red (`expected function to throw an error, but it didn't`), 228/232 still green, mutation and restore both verified on disk (blob hash back to HEAD, `git diff HEAD` empty). See the report comment for the full readings. ## Rework round 1 — CI fix (error-code provenance) CI was red on the prior head (`bd29ec386f`), job `Lint & Repo Gates`, step 120 "Error-code provenance guard" (`pnpm --filter @objectstack/spec check:error-code-provenance`) — reproduced locally first, quoting the gate's own message: ``` FAIL — 1 stamp site(s) of a registered code with no provenance row: @objectstack/lint stamps 'INVALID_ARTIFACT_PACKAGES' (assign) at packages/lint/src/object-graph.ts:278 — not listed under its own owner key ``` `packagesOf`'s `err.code = 'INVALID_ARTIFACT_PACKAGES'` is a genuine, independent stamp of an already-registered code (deliberately reused from `@objectstack/core`'s `resolveArtifactPackageOrder`, never minted new) — not a case where "a door in another package names the wire vocabulary" (the gate's waiver shape), since `packages/lint`'s rules are pure `(stack) => Finding[]` functions with no door of their own. Fixed the way the gate's own message prescribes: a new `'@objectstack/lint'` row in `packages/spec/src/api/error-code-ledger.zod.ts` listing `INVALID_ARTIFACT_PACKAGES`, with a comment recording the wire path (`door: 'none'`, the objectstack-ai#16449 reading already used for `@objectstack/spec`'s own `STACK_*` rows) — no allowlist, no waiver, no new code. Precedent: `3f9e2eaa1c`, "list plugin-security's class-field error codes under its own ledger key," which used the identical remedy and the identical `@objectstack/spec: minor` / `Clause-②: yes` changeset shape for a new owner-key row. ## Rework round 2 — pin gap and wording (at-tier record `5856823202`, items 1–2) - **Pin gap (item 1):** `validate-object-references.test.ts`'s non-array refusal test pinned only `[null, 42, 'core']`, while the other two validators already pinned `{}`. Added `{}` and a keyed object (`{ a: { manifest: {} } }`, the one shape `recordsOf` read as a map). (Round 3 found `validate-mapping-target-fields.test.ts` still lagged both on the same front — see below; only once that landed did all three validators reach parity.) - **Count, corrected (item 2a):** "four call sites" / "four copies" in the `packagesOf` docblock and the `object-graph.test.ts` describe-block comment now read "five … three files", matching the fifth site (`validate-mapping-target-fields.ts`, above) this PR already fixes. - **Core parity, qualified (item 2b):** on `main`, `resolveArtifactPackageOrder` (`packages/core/src/artifact-packages.ts:208`) still reads `null` as absent and names a refusal with `typeof`; PR objectstack-ai#20228 (objectstack-ai#19926) changes both, and has not landed. Every sentence claiming lint reads `null` "the way `resolveArtifactPackageOrder` does" or raises "the SAME code … for the identical defect" was only ever true for `{}` / `0` / `'x'` / a keyed object today — for `null` it is qualified with "once objectstack-ai#19926 (PR objectstack-ai#20228) lands" instead, in the `packagesOf` docblock, its inline naming comment, this changeset and the ledger-row comment. Wording only; no code changed. - **Door reachability, corrected (item 2c):** re-read `validate.ts:293`, `compile.ts:356`, `lint.ts:673`/`1140`, and `format.ts:369` (`printError`) directly. `os validate` and `os build` both run `ObjectStackDefinitionSchema.safeParse` before the lint readers ever run, and a malformed `packages` refuses there first — the lint reader is unreachable from those two doors for this defect. Only `os lint` reaches it: exit 1, the message on stdout via `printError` (not stderr), `code` present under `--json`. The lint changeset and this PR body (above) are corrected to say exactly that, not "`os validate` / `os lint` / `os build` … on stderr". - **Re-ablated** (same anchor as round 1, now against commit `68398a0e7e`, which carries every round-2 edit): `|| declared === null` restored into `packagesOf`'s absent branch — all four `null` pins (one per test file) went red, 228/232 still passed, mutation and restore both verified on disk (blob hash back to `HEAD`, `git diff HEAD` empty). The two new pins from item 1 (`{}` and a keyed object in `validate-object-references.test.ts`) stayed GREEN through this run — they assert a different branch (the non-null refusal path, untouched by this anchor), confirming the mutation is `null`-specific. Full readings in the report comment. ## Rework round 3 — the mapping validator's own pin gap (in-seat ruling `5857515836`, at-tier record `5857513507`, item 1) - **Pin gap:** `validate-mapping-target-fields.test.ts`'s non-array refusal loop pinned only `[{}, 0, 'x', null]` — no keyed object, and no explicit `packages: undefined` control (only the array control at the `objectExtensions` test above it). Added `{ a: { manifest: {} } }` to the loop, and a dedicated `packages: undefined` control test beside it — targeting `full_name` (a field `contact` declares directly), not `sla_tier` (which resolves via the STACK's own top-level `objectExtensions`, not a package's — `region`, in that same fixture, is the package-supplied one — and this control's minimal fixture declares no `objectExtensions` at all, so `full_name`, a field `contact` declares directly, is the one target that resolves regardless). All three validators now pin the same shape classes: `{}`, a number, a string, `null` and a keyed object, plus an array control and an explicit `undefined` control. - **Wording, corrected:** the round-2 sentence above and `validate-object-references.test.ts`'s matching comment both said "the identical set"/"the same set" before this fix landed, which was false — `validate-mapping-target-fields.test.ts` was still short two cases. Reworded to "the same shape classes" in both places; true now that this round closes the gap. - **Ablated:** pointed `scripts/ablation-replace.mjs` at `packagesOf`'s array-vs-everything-else branch (`if (Array.isArray(declared)) return declared.filter(isRec);`), replacing it with a version that also accepts any `isRec` value the old `recordsOf`-style way. There is no narrower branch to anchor on than this — `{}` and a keyed object share the exact same guard in the implementation, so an ablation of one is necessarily an ablation of both. 5 tests went red: `object-graph.test.ts`'s `{}` and keyed `it.each` cases explicitly, plus all three validators' refusal loops (each stops at its first affected element — `{}` is first in the mapping and translation loops, so the new keyed assertion at the end of the mapping loop is covered by that same failing test rather than isolated on its own). 228/233 still passed; `0` / `'x'` / `null` stayed refused throughout, untouched by this anchor. Mutation and restore both verified on disk (blob hash back to `HEAD`, `git diff HEAD` empty, `git status` clean). Full readings in the report comment. **Landing order**: PR objectstack-ai#20228 landed as `a9fb83ef06`; merged into this branch at `82dc0c9c01` (round 4 below, merge commit `3fef33e23b` plus one wording-fix commit) — `null-packages-follows-resolver.test.ts` leg 1 is green now. ## Pins `packagesOf` is pinned exhaustively in `object-graph.test.ts`: an array is the control (junk-dropping behaviour unchanged), an absent (`undefined`) `packages` stays silent, and `{}` / `0` / `'x'` / a keyed object / `null` are each refused with `code: 'INVALID_ARTIFACT_PACKAGES'`, `status: 422` (the `null` case additionally pins the message names `null`, not `object`). Each of the three public functions these readers sit behind (`validateObjectReferences`, `validateTranslationReferences`, `validateMappingTargetFields`) gets its own throw-pin proving the wiring reaches the shared reader, since all three now call the identical function. One existing pin asserted the OLD fall-through semantics and is flipped: `validate-object-references.test.ts`'s `'ignores a packages value that is not a list of entries'` (`null`, `42`, `'core'` all silently ignored) is now two tests — `undefined` stays the silence control, and `null` / `42` / `'core'` / `{}` / a keyed object (the last two added in round 2) now assert the refusal (code + status), matching the same shape classes the other two validators pin. ## Census (H2) Grepped the whole tree for a non-array `packages` fixture reaching any of the five readers: none besides the one flipped test above. `packages/spec/src/stack-artifact-packages.test.ts` tests the spec schema's own refusal at a different layer and is untouched. ## Gates Local, targeted (container under heavy multi-agent contention — most runs this round queued 5-20+ minutes on the shared `os-verify-lock`, one holder held it ~1150s straight; retried with a stable slot rather than enumerating the whole farm, per contract): - `pnpm --filter @objectstack/spec build && check:generated` — green, all 15 generated artifacts up to date (measured post-merge, against a tree that also absorbed 137 files' worth of unrelated `origin/main` movement — see "Post-merge" below). - `pnpm --filter '@objectstack/lint^...' build` (dependency closure incl. `@objectstack/spec` DTS + `@objectstack/formula`) — green. - `pnpm --filter @objectstack/lint typecheck` (`tsc --noEmit` + `check:test-typecheck`) — green, no new debt. - `pnpm --filter @objectstack/spec exec vitest run src/api/error-code-ledger.test.ts` — 21/21 passed. - `pnpm --filter @objectstack/lint exec vitest run` the five test files — **232/232 passed**, both before and after the merge. - `check:error-code-provenance`, `check:error-code-casing`, `check:dispatcher-error-vocabulary`, `check:strictness-ledger` — all green (the four families `dispatch-gates.mjs` newly derives once the diff touches `packages/spec/src/api/error-code-ledger.zod.ts`). - `check:adr-0087-registration` / `check:changeset-no-major` — green with the updated `yes (narrowing)` declaration (verified with a synthetic `pull_request` event carrying this PR's own line). - `check:nul-bytes`, `check:issue-citations`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:doc-authoring`, `check:type-check-coverage`, `check:published-files`, `check:watch-hint-literal` — all green (unchanged from round 0). **Post-merge**: `origin/main` moved on `packages/spec/src/api/error-code-ledger.zod.ts` (137 files total, mostly unrelated) between round-0 and this round; merged (`a4b05d6e15`, no rebase, no force-push) — clean, no conflicts, our new `'@objectstack/lint'` row and both stamp sites survived intact. Full rebuild + `check:generated` + typecheck + the six test files above all re-run and green against the merged tree. `dispatch-gates.mjs --ran` reconciliation this round: 13 of 86 now-derived families measured locally (the ones above, `check:error-code-provenance` included — this is the family whose local absence let the CI failure through last round); the rest are left to CI, mostly repo-wide `--self-test` checker-health invocations and generated-artifact sub-checks already covered wholesale by the green `check:generated` run above. **Round 2** (head `68398a0e7e`, wording-only + the item-1 pin addition — `packagesOf` semantics unchanged): re-derived `dispatch-gates.mjs --commands` after a fresh `git fetch origin main` — identical 86-family list to round 1 (diff empty), so nothing new to run and nothing skipped. `origin/main` re-checked three times this round (before the commit, before the ablation, and again here): still has not touched any file this PR touches (only `validate-rls-predicate-enforceability.*` and unrelated changesets) — no merge needed this round. Container restarted mid-round (~15:05Z) and killed the in-flight background verification; the worktree and its uncommitted diff survived, the diff was re-verified complete and committed (as `7be6204521`, tree identical to this head) before any ablation or long run, then re-run from scratch, all in the foreground under the shared lock with the same stable slot (`issue-20206-dev-r2`; two `queue-timeout (exit 99)` attempts before it landed — recorded as NOT MEASURED, not as failures, per contract). `check:commit-card-trailers` then refused the first push over a model name in the co-author trailer (this session's own harness-attribution reminder, which the repo's model-free-trailer contract overrides); the tip commit was unpublished, so amended in place to the model-free pair — `git commit --amend`, no force-push, tree byte-identical — landing as `68398a0e7e`: - `pnpm --filter '@objectstack/lint^...' build` — green. - `pnpm --filter @objectstack/lint typecheck` — green, same pre-existing debt as round 1 (2 files / 6 errors / 2 pinned signatures, unrelated, shrink-only), no new debt. - `pnpm --filter @objectstack/lint exec vitest run` the four `packagesOf`-reaching test files — **232/232 passed** (`object-graph.test.ts`, `validate-object-references.test.ts`, `validate-translation-references.test.ts`, `validate-mapping-target-fields.test.ts`). - `pnpm --filter @objectstack/spec exec vitest run src/api/error-code-ledger.test.ts` — 21/21 passed. - `check:error-code-provenance` — green: `self-test OK`, then `scanned 2477 files; 328 registered-code stamp site(s): 312 listed, 16 waived … every registered-code stamp site is listed under its own owner key or carries a recorded waiver (9 waiver(s), all live)`. - `check-adr-0087-registration.mjs --base origin/main` and `check-changeset-no-major.mjs --base origin/main --event` (a synthetic `pull_request` payload carrying this PR's real body, byte for byte) — both green, re-run post-commit; `readClause2Line` on the live body reads `{"kind":"declared","value":"yes","arm":"narrowing"}` — a clean declaration, not the near-miss the seat flipped to its own paragraph round 1 (still on its own line here). **Round 3** (head `42b3bfbf2e`, one bounded patch — `packagesOf`'s function body and the ledger's row entry unchanged since `a4b05d6e15`; their surrounding comments moved in round 2, `68398a0e7e`): PR objectstack-ai#20246 (`443b2f4fdc`) entered the merge queue at 15:58:56Z and reached `main` at 16:17:46Z: after round 3's check and commit (16:09:36Z, amended 16:17:08Z) and before its push (about 16:20Z). Round 4's merge picked it up cleanly. Under the shared lock (stable slot `issue-20206-dev-r3`, lock free both times, no queueing this round): - `pnpm --filter '@objectstack/lint^...' build` — green. - `pnpm --filter @objectstack/lint typecheck` — green, same pre-existing debt, no new debt. - `pnpm --filter @objectstack/lint exec vitest run` the four `packagesOf`-reaching test files — first pass caught a bug in the new control test itself (its mapping target `sla_tier` resolves via the STACK's own `objectExtensions`, not a package's — the control's minimal fixture declares no `objectExtensions` at all, so `packages: undefined` correctly produced a real finding rather than staying silent — fixed by retargeting the control at `full_name`, a field `contact` declares directly, amended into the same unpushed commit); re-run **233/233 passed**. - Ablation: see "Rework round 3" above — mutation landed, 5 tests red (`{}` and keyed pins across all four files, `0`/`'x'`/`null` unaffected), 228/233 passed, restore verified byte-identical to `HEAD`. - `check:commit-card-trailers` — green (model-free trailers carried through the amend). - Landing-order addendum: PR objectstack-ai#20228 has not merged as of this push (checked via the REST API right before pushing); pushed as planned, per the addendum's instruction for that case. ## Round 4 — merge (PR objectstack-ai#20228 landed as `a9fb83ef06`) PR objectstack-ai#20228 merged at 16:40Z. `git fetch origin main && git merge origin/main` (`3fef33e23b`, no rebase, no force-push) — clean, no conflicts. Diff stat, old head (`42b3bfbf2e`) → the merge commit: **287 files changed, 8926 insertions(+), 1939 deletions(-)**, split: - **From `main`**: all 287 files — verified by set-equality against `git diff --name-only a9fb83e origin/main` computed from the pre-merge merge-base (`ab820016b`): identical file lists both directions (`comm -23`/`comm -13` both empty). Nothing else moved. - **Anything else**: empty, by construction — the merge introduced no manual conflict resolution (`git status` was clean immediately after `git merge`, no file was hand-edited as part of it). One shared file, `validate-mapping-target-fields.ts` + its test, was touched by both sides: PR objectstack-ai#20246 (`443b2f4fdc`, "an import mapping target may name a declared part of a compound field") reached `main` at 16:17:46Z, between round 3's commit and its push. Git merged it automatically with no conflict — the new address-part-mapping tests PR objectstack-ai#20246 adds sit above our round-3 additions in the test file, which are untouched by the diff (confirmed directly: `git diff 42b3bfb HEAD -- packages/lint/src/validate-mapping-target-fields.test.ts` shows only PR objectstack-ai#20246's own hunks). A separate at-tier record on the round-3 head (`42b3bfbf2e`, before this merge) found the round-3 comment mis-attributing which `objectExtensions` source resolves `sla_tier` — fixed in `82dc0c9c01`, its own commit, folded into this same push: the fixture's STACK-level `objectExtensions` supplies `sla_tier`; `region` is the one that needs a package. The `full_name` retarget was already correct. That same correction is threaded through this PR body's round-3 bullets above. **Proof**, under the shared lock (stable slot `issue-20206-dev-r4`; severe contention — the `@objectstack/cli^...` dependency closure needed six attempts: four `queue-timeout (exit 99)` (NOT MEASURED, place kept each time), one killed by this session's own 590s foreground wrapper at 54/55 tasks cached from the partial run before it, then a clean finish): - `pnpm exec turbo run build --filter='@objectstack/cli^...' --concurrency=2` — green, 55/55 tasks. - `pnpm --filter @objectstack/cli exec vitest run test/null-packages-follows-resolver.test.ts` — **16/16 passed**, both legs. Leg 1 (`` `objectstack-ai#19925 leg 1: each reader answers `packages: null` the way the real resolver does` ``) includes the named case `` `os lint` lintConfig `` (`READERS[3]`, `:107`) — GREEN, now that `resolveArtifactPackageOrder` genuinely refuses `null` post-objectstack-ai#20228, matching `lintConfig`'s own refusal. Leg 2 (the resolver-double leg) is unaffected either way and stayed green throughout every round. - `pnpm --filter @objectstack/lint typecheck` — green, no new debt. - `pnpm --filter @objectstack/lint exec vitest run` the four `packagesOf`-reaching test files — **237/237 passed** (up from 233: PR objectstack-ai#20246 added 4 tests to `validate-mapping-target-fields.test.ts`; none of the new tests touch `packages`). - `check-adr-0087-registration.mjs --base origin/main` — green, re-run post-merge. - `check:commit-card-trailers` — green on both commits (the merge commit and the wording-fix commit). `origin/main` moved once more after this merge (`17bd318771`, unrelated `InlineAction`/`ViewMetadataParsed` spec types) — checked, touches none of this PR's files; not re-merged, since nothing to pick up. **Round 5** (head `a38a259df6`, wording only): with PR objectstack-ai#20228 in the head, every `null`-parity sentence now states core's refusal in the present tense: the `packagesOf` docblock, its `@throws`, the inline naming comment, the lint changeset and the ledger-row comment. The round-1/2 sections above that say "once … lands" are history. At `a38a259df6` none of the PR's files carries a conditional claim about objectstack-ai#20228 (`git grep` sweep: 0 hits). The seat corrected this body's objectstack-ai#20246 timing (the queue build at 15:58:56Z versus the landing on `main` at 16:17:46Z). ## Acceptance notes None. This PR's scope is exactly the five `recordsOf(stack.packages)` readers described above, their `null` leg (ruling A on objectstack-ai#19926), and the CI-fix ledger row the first two require — the ledger edit is outside the claim's originally declared file surface (`packages/lint/**` + `.changeset/`) but is the coordinator's explicit rework instruction, reproduced and fixed the way the gate itself prescribes. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20149
Clause-②: yes
What this does
The maintainer's ruling on #20149 (comment 5852138019, 「同意」):
An import mapping could write each source column to one flat field only, so nothing could build an
addressvalue from the street / city / state / postal code / country columns a spreadsheet carries. Now afieldMapping[].targetmay namefield.part, and the import door assembles every part one row maps into ONE value under the field's key, before the engine sees the row.It extends the ONE verdict #20150 built, at the arm that PR marked. There is no second predicate.
@objectstack/spec(packages/spec/src/data/import-mapping-target.ts):judgeImportMappingTargetgains the{ kind: 'part', target, field, part }arm.indexImportMappingTargetscarries each compound field's parts on a newpartsmap, read from the field's stored value schema (valueSchemaFor). The part names are never listed by hand.unknownwith ahead(what the text before the dot names, and its parts when it is compound), so every door can name the legal parts.unknownImportMappingTargetsgives each refused target areason:unknown, orcollidesfor a part of a field the same mapping also writes whole.ImportFieldMappingSchema.targetdeclares the part path in its.describe()and docblock. The generated reference page,api-surface/andexport-origins/are regenerated.@objectstack/rest(import-mapping.ts,import-prepare.ts):applyMappingToRowstakes the object definition,trimWhitespaceandnullValues, and assembles parts from every transform:none,map,constant,join, and each element of asplit.refuseUnknownMappingTargetsnames the legal parts in its refusal. It still answers400 INVALID_FIELD, before any row.isBlankis exported fromimport-coerce.ts, so a blank part is judged by the same rule as a blank cell.@objectstack/lint(validate-mapping-target-fields.ts): the message "a dotted path into a field's value is not a target" is gone, because it is false for a declared part. The rule now reports the three refused cases, each with the legal parts.content/docs/data-modeling/import-mappings.mdxthetargetrow said "Target field name(s)", which is now false. It is rewritten. Only the body is edited; the frontmatter is held by PR docs(content): apply the approved search-intent title rule to 169 authored pages, short nav labels kept via navTitle #20170.Decisions, each from a measurement
H1: which fields are compound. I checked every one of the 49
FieldTypevalues throughvalueSchemaFor. Two stored value schemas are closed objects (catchall: never):address: seven parts (street,city,state,postalCode,country,countryCode,formatted). Every part is an optional string.location:latandlngare required numbers;altitudeandaccuracyare optional numbers.A field counts as compound when its value schema is a closed object whose every part is an optional string. That selects
addressonly.locationis excluded, as the ruling asked me to decide and record:LocationValueSchemarefuses{ lat: '37.7', lng: '-122.4' }(strings) and{ lat: 37.7 }(nolng).The census test pins the whole set against
FieldType.options.H2: what one row assembles. Measured through the real engine (sqlite, JSON rows, no mapping) at
055d4b66e9, with the same result on the dry run and the commit:{ street, city }(partial){ street, city }{ street: '', city }(empty part){ street: '', city }{ postalCode: 12345 }(wrong type)invalid_type{ postalCode: 12345 }{ city }over{ street, city }{ city }(the stored value is replaced whole)The assembly rule, stated in
applyMappingToRows' docblock and the docs row:nullValuestoken.street: '', but a blank flat cell leaves its field unset, and a blank part does the same one level down.trimWhitespace, as a flat text cell is. Coercion never reaches inside a compound value, so the trim happens in the assembly.Whole field and part together. When a mapping writes
mailing_addressand alsomailing_address.street, both write the same key of one row. They are refused asreason: 'collides'at all three doors, naming where the whole field is written. A repeated part target follows the same last-write rule as a repeated flat target.One door. The dry run and the commit both reach the assembly through
prepareImportRequest, so they judge the same assembled row.objectstack validateasks the same spec verdict.H5: census of dotted targets at base
3875ae6773:examples/app-showcaseshowcase_inquiry_feed(5 entries), has no dotted target.skills/or docs page carries a dottedfieldMappingtarget.target:hit in the tree belongs to another schema: action or form targets, API endpointinputMapping/outputMapping, and the seed loader.Pins (the pin sweep)
fieldMapping.targetthat names no field passesobjectstack validateand the dry run, then fails every row on commit #20150 pinnedmailing_address.streetasunknowninimport-mapping-target.test.ts, and as a finding invalidate-mapping-target-fields.test.ts, on an object that declares that address field.part, and the card's full address template is green atvalidate.fieldMapping.targetthat names no field passesobjectstack validateand the dry run, then fails every row on commit #20150 integration pin keeps refusingmailing_address.streetontask, which declares no such field. Its comment now says why.import-integration.test.ts, sqlite):ok 3each.import-mapping.test.ts(blank, trim,trimWhitespace: false, every transform, two compound fields kept apart, no object definition means no part reading). The door's three refusal texts. The lint rule's three refusals.Tests (all through
os-verify-lock.sh, shared box; treef21f41dbea)@objectstack/spec:vitest run --project local: 542 files, 15945 passed, 2 todo.typecheck(tsc, scripts, test layer): exit 0.@objectstack/rest:vitest run --project local: 199 files, 3569 passed, 1 skipped.typecheck(tsc and test layer): exit 0. Therepoproject is declared to CI.@objectstack/lint:vitest run: 109 files, 4236 passed.typecheck: exit 0.@objectstack/cli(unit):test/validate-build-gate-parity.test.ts: 22 passed. Theintegrationlayer is declared to CI..tsfiles (--no-inline-config --format json): 10 files, 0 errors, 0 warnings.**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}minus the build directories, which all 10 files fall under.eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot move the verdict on any file it does not touch.pnpm lintis CI's.Ablations (one-shot; each through
scripts/ablation-replace.mjs, anchor hit 1, blob changed, restored to the HEAD blob withgit diff HEADempty)import-mapping-target.ts): 3 red, 18 green inimport-mapping-target.test.ts. The red tests are the part arm, the address-by-parts mapping and the collision.import-mapping.ts): 5 red, 57 green.ok 3while the commit answeredok 1, errors 2. That is the card's own dry-run-versus-commit defect, reproduced.validate-mapping-target-fields.ts): 2 red, 11 green (the unknown-part and no-parts refusals).Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 108 families atf21f41dbea, and--ranreconciles them: 105 run green, 3 NOT MEASURED, 0 unrun.check:skill-examplesneeds aclient-reactbuild. The shared lock never granted one inside its budget.check:dual-build-cjs-loadsandcheck:type-check-debtread a whole-repo build that this worktree does not have.check:generated: all 15 artifacts are up to date aftergen:api-surface,gen:export-originsandgen:docs, which are committed.check:adr-0087-registration: green. The changeset declares a widening only (Clause-②: yes), so no disposition marker applies.origin/mainab820016b3, from a bare clone with no regen driver: clean.Acceptance notes
addressis compound for import. Alocationfield still imports whole, as one JSON object. Adding it would need a per-part number coercion and a required-part check. Carrier: none.postalCode. That value passes through to the engine's value-shape check, which admits it with a warning on a warn-first deployment (measured above). This is the posture every structured value on the import path already has. Carrier: none.mapping-target-field-unknownand the existing door codeINVALID_FIELD. No new rule id or error code was minted.head.parts,index.parts), as import mapping: afieldMapping.targetthat names no field passesobjectstack validateand the dry run, then fails every row on commit #20150 left them.Generated by Claude Code