Skip to content

feat(spec,rest,lint): an import mapping target may name a declared part of a compound field (mailing_address.street) - #20246

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20149-import-mapping-compound-parts
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20149-import-mapping-compound-parts

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20149

Clause-②: yes

What this does

The maintainer's ruling on #20149 (comment 5852138019, 「同意」):

Ruled: A — a mapping target may name a declared part of a compound field (mailing_address.street); the importer assembles the parts into one value; the part names are the closed set the value schema declares.

An import mapping could write each source column to one flat field only, so nothing could build an address value from the street / city / state / postal code / country columns a spreadsheet carries. Now a fieldMapping[].target may name field.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):
    • judgeImportMappingTarget gains the { kind: 'part', target, field, part } arm.
    • indexImportMappingTargets carries each compound field's parts on a new parts map, read from the field's stored value schema (valueSchemaFor). The part names are never listed by hand.
    • A dotted target that stays refused answers unknown with a head (what the text before the dot names, and its parts when it is compound), so every door can name the legal parts.
    • unknownImportMappingTargets gives each refused target a reason: unknown, or collides for a part of a field the same mapping also writes whole.
    • ImportFieldMappingSchema.target declares the part path in its .describe() and docblock. The generated reference page, api-surface/ and export-origins/ are regenerated.
  • @objectstack/rest (import-mapping.ts, import-prepare.ts):
    • applyMappingToRows takes the object definition, trimWhitespace and nullValues, and assembles parts from every transform: none, map, constant, join, and each element of a split.
    • refuseUnknownMappingTargets names the legal parts in its refusal. It still answers 400 INVALID_FIELD, before any row.
    • isBlank is exported from import-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.
  • Docs: in content/docs/data-modeling/import-mappings.mdx the target row 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 FieldType values through valueSchemaFor. 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: lat and lng are required numbers; altitude and accuracy are optional numbers.

A field counts as compound when its value schema is a closed object whose every part is an optional string. That selects address only. location is excluded, as the ruling asked me to decide and record:

  • LocationValueSchema refuses { lat: '37.7', lng: '-122.4' } (strings) and { lat: 37.7 } (no lng).
  • On the ADR-0104 warn-first write path, the engine admits a value that fails its value schema and only warns. I measured this below with an address part holding a number.
  • So a location assembled from text cells would be stored with the wrong type, and nothing would refuse it. With optional-string parts, every subset of text cells is a valid value by construction.

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:

value sent dry run commit read back
{ street, city } (partial) ok ok { street, city }
{ street: '', city } (empty part) ok ok { street: '', city }
{ postalCode: 12345 } (wrong type) ok, with warning invalid_type ok { postalCode: 12345 }
update { city } over { street, city } ok { city } (the stored value is replaced whole)

The assembly rule, stated in applyMappingToRows' docblock and the docs row:

  • A blank part cell contributes nothing. Blank means empty, whitespace, or a nullValues token.
    • The schema would accept street: '', but a blank flat cell leaves its field unset, and a blank part does the same one level down.
  • A string part is trimmed under trimWhitespace, as a flat text cell is. Coercion never reaches inside a compound value, so the trim happens in the assembly.
  • If every part a row maps is blank, the field is left unset.
  • Otherwise the assembled object is the field's whole new value. On an update it replaces the stored one.

Whole field and part together. When a mapping writes mailing_address and also mailing_address.street, both write the same key of one row. They are refused as reason: '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 validate asks the same spec verdict.

H5: census of dotted targets at base 3875ae6773:

  • The repo's one import mapping, examples/app-showcase showcase_inquiry_feed (5 entries), has no dotted target.
  • No skills/ or docs page carries a dotted fieldMapping target.
  • Every dotted target: hit in the tree belongs to another schema: action or form targets, API endpoint inputMapping / outputMapping, and the seed loader.
  • hotcrm (the card's origin, hotcrm#1836) is NOT MEASURED from here.

Pins (the pin sweep)

  • Flipped:
  • New, real engine (import-integration.test.ts, sqlite):
    • The customer template's five columns over three rows (full, partial, all blank).
    • The dry run and the commit agree row for row: ok 3 each.
    • Read back: one address object, a two-part address, and an unset field.
    • An unknown part and a dotted path on a text field are refused on both paths with identical bodies and the part list named, and nothing is written.
    • The whole-plus-part collision is refused on both paths.
    • The control mapping with no dotted target passes on both.
  • Unit: the assembly rule in 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; tree f21f41dbea)

  • @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. The repo project 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. The integration layer is declared to CI.
  • ESLint over the 10 changed .ts files (--no-inline-config --format json): 10 files, 0 errors, 0 warnings.
    • The population is the config's **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} minus the build directories, which all 10 files fall under.
    • eslint.config.mjs enables no type-aware linting (no parserOptions.project), so this diff cannot move the verdict on any file it does not touch.
    • The repo-wide pnpm lint is CI's.

Ablations (one-shot; each through scripts/ablation-replace.mjs, anchor hit 1, blob changed, restored to the HEAD blob with git diff HEAD empty)

  • A. The spec part arm answers nothing (import-mapping-target.ts): 3 red, 18 green in import-mapping-target.test.ts. The red tests are the part arm, the address-by-parts mapping and the collision.
  • B. The rest assembly is off (import-mapping.ts): 5 red, 57 green.
    • 4 red are the assembly unit tests; the control stays green.
    • The fifth is the integration test. With assembly off, the dry run still answered ok 3 while the commit answered ok 1, errors 2. That is the card's own dry-run-versus-commit defect, reproduced.
  • C. The lint dotted reason is dropped (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 --commands derived 108 families at f21f41dbea, and --ran reconciles them: 105 run green, 3 NOT MEASURED, 0 unrun.

  • NOT MEASURED, each exit 3 (PREREQUISITE NOT MET):
    • check:skill-examples needs a client-react build. The shared lock never granted one inside its budget.
    • check:dual-build-cjs-loads and check:type-check-debt read a whole-repo build that this worktree does not have.
  • check:generated: all 15 artifacts are up to date after gen:api-surface, gen:export-origins and gen:docs, which are committed.
  • check:adr-0087-registration: green. The changeset declares a widening only (Clause-②: yes), so no disposition marker applies.
  • A mergeability probe against origin/main ab820016b3, from a bare clone with no regen driver: clean.

Acceptance notes

  • Only address is compound for import. A location field still imports whole, as one JSON object. Adding it would need a per-part number coercion and a required-part check. Carrier: none.
  • A JSON-format row can put a non-string value in a part, such as a number for 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.
  • On an update, the assembled value replaces the stored address whole (measured). Merging parts into the stored value would be a new decision. It is documented in the docs row and the changeset. Carrier: none.
  • The collision refusal travels under the existing rule id mapping-target-field-unknown and the existing door code INVALID_FIELD. No new rule id or error code was minted.
  • The spec verdict is shared. The door and the lint rule each phrase their own sentence from it (head.parts, index.parts), as import mapping: a fieldMapping.target that names no field passes objectstack validate and the dry run, then fails every row on commit #20150 left them.

Generated by Claude Code

…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>
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json fdb26698f975511a01a01f53d506058977db7cef.

⛔ 2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 138 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 fdb26698f975511a01a01f53d506058977db7cef → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f0403634169759059d809c2bd1bf41c1e5e89fc3 — the merge of head e652a77395cfd4a2ad83724f84a68fbaa08ae2ee into base fdb26698f975511a01a01f53d506058977db7cef, 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 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

⚠️ 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 fdb26698f975511a01a01f53d506058977db7cef → 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: 41/41 CONTRACT_REVIEW_TIER
Head-sha: f21f41dbeac82a005d0eed61d9d0a79523da193e

① Derived judgments

BLOCKING (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." packages/rest/src/import-mapping.ts gates the parts sentence on if (unknown.length > 0) sentences.push(... partsHint(objectSchema)), so a collision-only refusal carries none; the lint collision finding's hint is exactly Map the field whole or by its parts, not both. (pinned in validate-mapping-target-fields.test.ts line 120, no part list in message or hint). False at head: changeset "reports what stays refused, naming the legal parts each time"; lint docblock "names the legal parts each time: ... and a part of a field the same mapping also writes whole"; rest docblock "What stays refused here, each naming the parts that ARE legal: ... a part of a field the same mapping also writes whole"; PR body "each with the legal parts". Smallest fix: emit partsHint unconditionally in refuseUnknownMappingTargets and append the compound list to the lint collision hint, then update the two exact-string pins (import-integration.test.ts collision case, lint test line 120). Fallback: strike "each time" / "each naming" in the three texts, which leaves ruling item 3 unmet for one of three refusals.

  1. One verdict: the part arm sits in judgeImportMappingTarget after index.names.has(target) (import-mapping-target.ts line 249). Rest door (refuseUnknownMappingTargets) and applyMappingToRows both read the spec verdict; lint reads unknownImportMappingTargets and its only indexOf('.') (line 161) slices a message from a spec reason. objectstack validate runs the lint rule via reference-integrity-suite.ts:436; both import routes call prepareImportRequest (rest-server.ts 10198, 10301). Legal part: part / undefined / [] / ok 3 both paths. Unknown part: unknown with head.parts; 400 INVALID_FIELD naming seven parts; lint names seven; dry body equals commit body. Dotted on non-compound: refused at all three, lookup phrasing for reference types. Collision: reason:'collides' at all three (parts not listed, above). geo.lat: unknown, head type location, refused at all three.
  2. Derived: importTargetPartsOf reads valueSchemaFor(def,'stored'), requires def type object, catchall never, every member optional string; keys from shape. Census pins ['f_address'] over FieldType.options. Ruling item 1: "whether location is included is measured by the dev against the spec and written down either way" — the exclusion is delegated; no owner needed. Nothing says "every compound field". No third strict-object stored schema exists (composite/record are z.record, stored file is an id).
  3. Assembly: write() drops isBlank(value, nullValues) parts (same function coerceRow uses), trims when trimWhitespace !== false (flat cells: import-coerce.ts:427), writes the field only when non-empty. Update-replaces-whole is engine behaviour outside the diff, measured once, not pinned. Both paths take the assembled row from prepareImportRequest; the integration pin compares results row for row. split trims regardless of trimWhitespace (pre-existing).
  4. No narrowing: base ab820016b3 carries import mapping: a fieldMapping.target that names no field passes objectstack validate and the dry run, then fails every row on commit #20150, so every dotted target was unknown and refused at all three doors, including the collision shape's dotted half. Flat path of applyMappingToRows is unchanged (mapped[target] = value; control pin without objectSchema). Flat-unknown refusal keeps code, status and opening sentence. yes is right.
  5. Other text: mapping.zod.ts docblock "read from the field's value schema by judgeImportMappingTarget" — read by indexImportMappingTargets/importTargetPartsOf; imprecise, not false enough to block. Changeset "was refused at objectstack validate, on the dry run and on the commit" is true against main but .changeset/20150-...md is also unreleased, so a 17.4.0 reader (validate silent, dry run ok 44) meets a state no release had. Docs row and generated reference: true.
  6. Pins that stay green on guard removal: update-replaces-whole (none); in importTargetPartsOf, dropping the optional check alone or the string check alone (location fails both), dropping catchall never or the empty-shape guard (no stored schema is open or empty); the unknown.length > 0 gate on partsHint (no pin either way). Ablation legs A/B/C, the trim, N/A, all-blank c3, and lookup-phrasing pins go red on removal.

② Semver level

minor for spec, rest, lint in .changeset/20149-import-mapping-compound-parts.md; Clause-②: yes on the body and changeset. Widening only: new kind:'part' member, ImportMappingTargetHead (api-surface +1), parts on the index, reason/head/wholeAt, optional third param, isBlank export (not in the rest barrel). Not yes (narrowing) per item 4. ADR-0087: check-adr-0087-registration.mjs:639-640 signals only on arm narrowing; no banner; no disposition needed — right. CI at head: 24 success, 5 skipped, 8 in progress (Test Core 1-6, Type Check workspace, Lint & Repo Gates), 0 failed at review time.

③ Boundary flags

  • location whole-only: matches ruling item 1's delegation; recorded in module note, census, changeset. OK.
  • JSON non-string part to warn-first check: pre-existing posture; the body's own table shows dry run "ok, with warning", commit "ok" — unpinned, CSV-only pins. OK as a note.
  • Update replaces stored value whole: documented, unpinned; merge would be a new decision. OK.
  • Collision under mapping-target-field-unknown / INVALID_FIELD: consistent with triage ④ "no new keys or enum values". OK.
  • Each door phrases its own sentence from the shared verdict: that is where the collision refusal lost the parts list (blocking item).
  • Unlisted: lookup transform on a part target assembles raw text as none would (reference resolution keys on the field, an address); not ruled, not pinned.
  • Frontmatter of import-mappings.mdx held by docs(content): apply the approved search-intent title rule to 169 authored pages, short nav labels kept via navTitle #20170: respected.

Implemented-by: claude/issue-20149-import-mapping-compound-parts
Reviewed-by: session_01QcAS3qiYYZNezaxZxaUdMV

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 64/64 CONTRACT_REVIEW_TIER
Head-sha: e652a77395cfd4a2ad83724f84a68fbaa08ae2ee

① Derived judgments

  1. Delta. PR re-read: head still e652a77395. git diff f21f41dbea e652a77395 --stat: 6 files, +24/−11, exactly the six named. Rest door import-mapping.ts:176: sentences.push(\Point each target at a field the object declares${partsHint(objectSchema)}.`)is now unconditional (was gated on an unknown target being present). Probe at head (tsx in a throwaway worktree, spec resolved from source, removed after): collision → "…Map the field whole or by its parts, not both. Point each target at a field the object declares, or at a declared part of a compound field as field.part (mailing_address: street, city, state, postalCode, country, countryCode, formatted)."; unknown part → "(the address field "mailing_address" declares the parts street, …, formatted)" plus the same tail;full_name.first→ "(the text field "full_name" has no parts)";account.name→ "has no parts: a dotted target never traverses a reference, so map the column to "account" with transform "lookup"";geo.lat→ "(the location field "geo" has no parts)"; every one400 INVALID_FIELDending with the parts tail. Lint:compoundPartsis built once per mapping fromindex.parts; the collision hint is exactly "Map the field whole or by its parts, not both. A declared part of a compound field is written field.part (mailing_address: street, …, formatted)."; the unknown-part, dotted and locationhints carry "or at a declared part of a compound field as field.part (mailing_address: …)" and each message names the head's parts or "with no parts". Same index, no copy: restpartsHintand lintcompoundPartsboth readindex.parts←indexImportMappingTargets←importTargetPartsOf←valueSchemaFor(def, 'stored'); git grep postalCodeover the five source files hits only the docblock atimport-mapping-target.ts:71. Pins: ablating R1 (re-gate the rest sentence) and R2 (drop the list from the lint collision hint) flipped exactly the three collision needles (rest unit import-mapping.test.ts:254, integration import-integration.test.ts:859, lint exact hint :120-122) to false and left every other case listed; restored, git status` clean.

  2. Four texts, true at head. Changeset "reports what stays refused, naming the legal parts each time"; lint docblock "names the legal parts each time: … and a part of a field the same mapping also writes whole"; rest docblock "each naming the parts that ARE legal"; PR body "The rule now reports the three refused cases, each with the legal parts". mapping.zod.ts:194-198 "read from each field's value schema when indexImportMappingTargets indexes the object (its module-private importTargetPartsOf …); judgeImportMappingTarget, the one verdict every door asks, then judges a field.part target against that set": importTargetPartsOf is unexported and called only from indexImportMappingTargets; the judge reads index.parts.get(name). True.

  3. Everything else (nothing moved beyond the one commit). One verdict: the part arm sits in judgeImportMappingTarget after index.names.has(target); the door, applyMappingToRows and lint all reach it (unknownImportMappingTargets or direct). Three doors: rest-server.ts:10198 and :10301 both call prepareImportRequest; the lint rule is wired at reference-integrity-suite.ts:436; the integration pin asserts dry._json equals commit._json on every refusal and row-for-row shapes on the legal template. Part set: AddressSchema is a strictObject of seven z.string().optional(); LocationValueSchema (field-value.zod.ts:395) is the only other strictObject( in the file and fails the optional-string test; census pin ['f_address'] over FieldType.options; ruling item 1 delegates location to the dev's measurement, recorded in module note, test, changeset and docs. Assembly: write() drops isBlank(value, nullValues) (the function coerceRow uses, exported, not in the rest barrel), trims when trimWhitespace !== false, sets the field only when non-empty; update-replaces-whole is engine behaviour, measured in the body, documented, unpinned. No narrowing: with no compound field partsHint is empty and the flat-unknown message is byte-identical to main's; code and status unchanged; every dotted target was already unknown on main via import mapping: a fieldMapping.target that names no field passes objectstack validate and the dry run, then fails every row on commit #20150.

  4. Other sentences. Docs row, generated reference row and .describe() are true. Pre-existing looseness, not this PR's: import-mappings.mdx:97 "one entry per target field" (a split, and now a part set, writes one field from several entries) and "the verdict comes from the engine's own write-path validation" (the target refusal is the door's since import mapping: a fieldMapping.target that names no field passes objectstack validate and the dry run, then fails every row on commit #20150). Non-blocking. The body's Tests and Ablations cite tree f21f41dbea; true of that tree, with R1/R2 recorded in the dev report at head.

CI at head: 35 check runs, 33 success, 2 skipped (Console Pin Gate, Packed-tarball smoke), 0 failed.

② Semver level

.changeset/20149-import-mapping-compound-parts.md: spec, rest, lint minor; Clause-②: yes line-leading in changeset and body, the spelling CLAUSE2_KEY_LINE reads (scripts/pm/clause2-line.mjs:124). Widening only: new verdict member, ImportMappingTargetHead (api-surface +1), parts, reason/head/wholeAt, an optional third parameter, the isBlank export. check-adr-0087-registration.mjs:587 signals only on the arm narrowing; no arm, no banner, so no disposition. Right.

③ Boundary flags

  • location imports whole only: ruling item 1 delegated it; recorded in four places. OK.
  • JSON non-string part passes to the warn-first shape check: pre-existing posture, measured in the body, unpinned. OK.
  • Update replaces the stored address whole: documented; a merge would be a new decision. OK.
  • Collision under mapping-target-field-unknown / INVALID_FIELD: no new id or code, consistent with triage ④. OK.
  • Each door phrases its own sentence from head.parts / index.parts: both read the one index; the collision gap is closed at head. OK.
  • Unlisted in the body: a lookup transform on a part target assembles raw text; in the dev report only, neither ruled nor pinned.
  • Frontmatter of import-mappings.mdx held by docs(content): apply the approved search-intent title rule to 169 authored pages, short nav labels kept via navTitle #20170: respected (the three-dot diff touches only the row). docs(content): apply the approved search-intent title rule to 169 authored pages, short nav labels kept via navTitle #20170 has since landed on main; git merge-tree --write-tree origin/main e652a77395 is clean, and the repo's merge driver asks for api-surface/data.json and export-origins/data.json to be regenerated from the merged tree, main having moved +4 lines on each.

Implemented-by: claude/issue-20149-import-mapping-compound-parts
Reviewed-by: session_01QcAS3qiYYZNezaxZxaUdMV

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 15:57
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 443b2f4 Sep 27, 2026
40 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20149-import-mapping-compound-parts branch September 27, 2026 16:17
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
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 protocol:data size/xl tests tooling

Projects

None yet

2 participants