Skip to content

fix(core)!: an import reads which fields are references through the spec's arbiter, so a user field without reference resolves against sys_user (#22785) - #22818

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22785-import-user-field-target
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22785-import-user-field-target

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22785
Clause-②: yes (narrowing)

What changes

buildFieldMetaMap (packages/core/src/utils/import-field-meta.ts) now asks the spec's arbiter referenceTargetOf both WHETHER a field is a reference and WHAT its target is. Before, only a field carrying a reference string reached the arbiter. So a user field written without reference was not a reference at the import, export and import-template doors, although the arbiter (and the data door's $expand) reads it as sys_user.

Door readings, before and after

Real boot through the dogfood harness (SecurityPlugin, ObjectQL, SQL driver, REST). Base 9f5eca52b3 against the fix ce23f40ae8, with @objectstack/core rebuilt and its dist checked. The fixture object has owner written as { type: 'user' } (no reference) and assignee written as Field.user(). The administrator and the member answered identically, except on the hidden-user row.

Door Input Before After
POST /data/:object/import and /import/jobs owner = a user's email reference_not_found ("no sys_user record has id ..."), nothing stored stores the user's id
same CONTROL assignee (Field.user) = the same email stores the id stores the id
same owner = a visible user's id stores stores
same owner = an unknown email reference_not_found (the write's sentence) reference_not_found ("no record matches ...")
same, member owner = the id of a user outside the member's organizations (the member's GET /data/sys_user/ID answers 404) stores reference_not_found; CONTROL assignee answers reference_not_found in both
GET /data/:object/export (JSON and CSV) owner holding the admin's id the raw id the user's name ("Dev Admin"), same as assignee
GET /data/:object/export?template=true owner instructions row "The name of a record, or its id." "The name of a User record, or its id."
connector pull (pullConnectorSource, stub protocol, built core) owner_email mapped to owner the raw email written as the id (ok) the user's id written; an unknown email is reference_not_found
plugin-auth identity import sys_user's served map (26 fields) manager_id to sys_user, primary_business_unit_id to sys_business_unit; 0 reference-typed fields without reference identical
data door POST /data/:object (not this change) owner = the hidden user's id 201 201
  • The connector-pull before reading and the hidden-user before reading were taken under ablation A1 below: main's guard restored and core rebuilt.
  • Two rows are a narrowing or a change to published output, and the changeset names both under its BREAKING banner: the hidden-user import row (stored before, refused now) and the export column (the stored id before, the user's name now). Each moves to the answer a Field.user field already gives.

Pins

  • core import-runner-reference-exposure.test.ts: the security(import): an import's reference resolution matches a lookup cell against a target whose exposure refuses reads (census row 7 of #22661) #22739 control block (owner with no target) is replaced by four cases.
    • The map names sys_user for both user fields, and no target for a non-reference type or for a non-string carrier.
    • An email in the field without reference stores the user's id.
    • CONTROL: Field.user does the same.
    • CONTROL: an unserved sys_user (apiEnabled: false) refuses both cells, and findData is never called.
  • rest export-format.test.ts: referenceFieldNames includes the field without reference. import-template.test.ts: its instructions name User.
  • dogfood import-user-field-target.dogfood.test.ts: the sync and jobs doors for owner and for the Field.user control, plus the export door.

Ablations

Each ablation ran on a committed tree through scripts/ablation-replace.mjs (WRAP mode). Each restore was proven: blob equals HEAD and git diff HEAD is empty.

Verification

The package suites ran at 4b78f72c5f. The next merge, to 272f15bd62 (origin/main 098481744f), brought only plugin-webhooks, the lockfile and scripts/engine-double-contract.pinned.json. The dogfood pins were re-run at 272f15bd62.

  • core: test 93 files / 2352 passed; test:repo 5 / 55, the enumeration pin second-object-read-exposure.pin.test.ts included; typecheck exit 0 (check:test-typecheck OK).
  • rest: the 38 test files reaching the import, export and template doors: 1225 passed, 22 skipped. typecheck exit 0.
  • plugin-auth: admin-import-users 2 files / 50. service-automation: connector-pull 3 files / 22.
  • dogfood: typecheck exit 0. The two pins (this one and security(import): an import's reference resolution matches a lookup cell against a target whose exposure refuses reads (census row 7 of #22661) #22739's import-reference-exposure) passed 16 / 16 at 272f15bd62.
  • Each new or edited test file is in its package's tsc program (--listFiles).
  • Gates at 272f15bd62.
    • dispatch-gates --commands derived 68 gates; the dispatch's 50 are a subset. All 68 ran, and each exited 0.
    • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (8 packages had no dist). After those packages were built it exited 0.
    • --ran reconcile: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN.
  • Lint, narrowed, at 272f15bd62. eslint --no-inline-config over the 5 changed .ts files.
    • Population from --print-config: 5 to 6 active rules each, none ignored.
    • --format json: 5 files, 0 errors, 0 warnings.
    • eslint.config.mjs enables no type-aware linting, so no untouched file's verdict can move. Repo-wide lint belongs to CI.
  • Round 2 (changeset text only, head 7fffd20e84, no code change). check-adr-0087-registration --base origin/main 0 ([BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)); check-changeset-no-major --base origin/main 0; check-empty-changeset --base origin/main 0; check:changeset-gate-self-tests 0; check:nul-bytes, check:issue-citations (both spellings), check:doc-authoring, check:pm-changeset-deadline-census, check:objectui-changeset 0. Re-derived dispatch-gates --commands: the same 68 gates as above.
    • The changeset's per-door section is now labelled "Door by door, before and after". The gate read the old label, "FROM → TO", as a migration prescription, which contradicted no-migration-prescription. The bullets are unchanged.
  • origin/main has since moved to 23419bafb7. Its one file overlapping this branch's reads, the enumeration pin, changes a different row (the relation-filter one). git merge-tree is clean, so the merge is left to CI and the queue.

Acceptance notes

  • Clause-② arm. The seat ruled the arm yes (narrowing): the hidden-user id moves from stored to refused, and the export column moves from the stored id to the user's name. The changeset therefore carries fix(core)!, the **BREAKING** banner naming both populations, and the ADR-0087 marker not-required (no-migration-prescription). It also adds a one-line fix for a reader that needs the stored id: the record read (GET /api/v1/data/:object, without expand).
  • buildFieldMetaMap serves the export and template doors from packages/rest, so their answers moved with no packages/rest source change. They are pinned by test only.

Generated by Claude Code

…ough the spec arbiter, so a user field without reference targets sys_user

Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
Co-authored-by: Claude <noreply@anthropic.com>
…mplate doors, with Field.user and the unserved-target refusal as controls

Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
Co-authored-by: Claude <noreply@anthropic.com>
…eld targets through the spec arbiter

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

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/releases/v17/index.mdx (via ExportFieldMeta (symbol, a top-level interface))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 27 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 27f0d83fb83bab61398ead3e8bbc0f529f0ee9fb → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 27f0d83fb83bab61398ead3e8bbc0f529f0ee9fb

⚠️ 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 27f0d83fb83bab61398ead3e8bbc0f529f0ee9fb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…, with the BREAKING banner and its ADR-0087 disposition

Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
Co-authored-by: Claude <noreply@anthropic.com>
…t as a migration mapping

Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot changed the title fix(core): an import reads which fields are references through the spec's arbiter, so a user field without reference resolves against sys_user (#22785) fix(core)!: an import reads which fields are references through the spec's arbiter, so a user field without reference resolves against sys_user (#22785) Oct 11, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7fffd20e8449fa6adb7591678f403330e76ee8c2
Local-runs: none

Inputs, and nothing else: card #22785 (body; comments 6106426530 triage, 6106696904 claim, 6107672102 round-1 dev report, 6107745753 round-2 dev report), the precedent 6106439614 (#22739's contract review on PR #22770), PR #22818 (title, body, its 6-file list, its 7-commit list, and the net diff from the merge-base 098481744f to the head: 6 files, +223/−29, byte-matching the file list; the last two commits d5798c5afc and 7fffd20e84 are the changeset-only round 2, two single-parent commits fast-forwarded onto 272f15bd62), the tree at the head where a judgment needed a caller or a definition read (git show / git grep against the fetched object, no checkout: import-field-meta.ts, field-value.zod.ts, import-coerce.ts, import-runner.ts, export-format.ts, import-template.ts, import-prepare.ts, rest-server.ts, connector-pull.ts, admin-import-users.ts, sys-user.object.ts, the semantic ledger entry 17.export-field-meta-constraints-retired.ts), scripts/check-adr-0087-registration.mjs (its header, CATEGORIES, findMigrationPrescription, labelPositioned, carriesConcreteRewrite, the placeholder and vertical-block regexes, the refusal text) with pr-automation.yml read for where it runs, the merged changeset .changeset/22737-relation-condition-target-exposure.md on origin/main for comparison, git merge-tree --write-tree of the head against origin/main e0785066ea (object store only), and the head's check-runs, read 2026-10-11T10:05Z. Of 41 check-runs (the push's workflows and the PR Automation re-run on the round-2 body edit): 31 concluded success (Build Core, Test Core 1/6 and 6/6, Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Temporal Conformance, Type Check · consumer gates / debt ledger / source gates / workspace, TypeScript Type Check, Governed Surface Queue Guard, Spec property liveness, Check Documentation Links, Flag docs affected, filter, Auto Label and Check PR Size on the push run, and Check Changeset with the four claim / branch / part-of guards on both runs), 5 skipped by their own triggers (Build Docs, Console Pin Gate, Packed-tarball smoke; Auto Label and Check PR Size on the re-run only), and 5 still in_progress at this read: Lint & Repo Gates and Test Core 2/6 through 5/6. This record judges the diff and what has concluded; the five are the owning seat's to read before the queue, and a red among them is a gate verdict that stands on its own, not a question this record answers. Security card: classes, positions and functions only.

① Derived judgments

  1. The arbiter now decides WHICH fields are references (@objectstack/core, buildFieldMetaMap). The guard moves from typeof f.reference === 'string' to f.reference == null || typeof f.reference === 'string'; the value stays referenceTargetOf(f). Read against the arbiter at the head: it answers none for a type outside REFERENCE_VALUE_TYPES (lookup, master_detail, user, tree), a declared string wins, otherwise IMPLICIT_REFERENCE_TARGETS, whose one row is user → sys_user. So the set this map calls references moves by exactly one population: a user field whose reference is absent or null, which now names sys_user. A lookup / master_detail / tree without reference stays targetless (no implicit row); a user field with an empty-string reference already took the arbiter's path on main (referenceCarrierOf reads '' as absent) and answers the same; a null carrier joins the absent one. Right, and it is the card's direction: no second list, the one arbiter $expand reads.

  2. The non-string carrier stays out of the arbiter's throw. referenceCarrierOf throws a TypeError on a present non-string (object, array, number); the guard keeps such a field out, so it names no target, as on main, with no throw — the security(import): an import's reference resolution matches a lookup cell against a target whose exposure refuses reads (census row 7 of #22661) #22739-reviewed property (①.8 there). Pinned (broken: { type: 'user', reference: { object: 'sys_user' } } → none) and ablated (A3: 4 red, each the arbiter's TypeError). Right. The dev's deviation (2), keeping the guard instead of calling the arbiter unconditionally, is the right reading of the direction, not a departure from it.

  3. Every newly resolved target still passes servesReferenceTarget before any read. The runner is untouched; at the head resolveRef (import-runner.ts) asks servesReferenceTarget(p, referenceObject) once per target ahead of refCache and answers {} when the target is not served. The new population reaches that resolveRef through coerceRow's reference branch (import-coerce.ts), which resolves only when meta.reference is set — it is now, so the cell is matched; on main the !meta?.reference short-circuit stored the raw value "and let referential integrity be enforced downstream", which is the write's reference_not_found the card measured. The core CONTROL (an unserved sys_user, apiEnabled: false, refuses both cells with findData never called) and ablation A2 (the exposure ask removed: the new control reds beside the nine security(import): an import's reference resolution matches a lookup cell against a target whose exposure refuses reads (census row 7 of #22661) #22739 withheld legs) measure that the widening rides the security(import): an import's reference resolution matches a lookup cell against a target whose exposure refuses reads (census row 7 of #22661) #22739 ask, not around it. Right; the triage's third pin is met.

  4. The hidden-user narrowing. A member pasting the id of a user outside its organizations: on main the cell was not resolved, the raw id reached the write, and the write's own reference check (elevated) stored it; now the cell is resolved as the caller, the member's row scope hides the user, resolveRef answers {}, and coerceRow turns that into reference_not_found — the answer a Field.user field already gives, by the same mechanism. Right, in the fail-closed direction and named in the changeset's banner. Two facts stated rather than hidden: (a) it is measured by the dev's probe at the real sync door (A1b, after the void first reading) and carried by no pin at the head — the dogfood legs are an email on both doors and both fields as the administrator, plus export; the Field.user control's own hidden-user answer is unpinned here too; (b) the data door's POST /api/v1/data/:object still accepts that id (the dev's 201 control), so the import and data doors answer a hidden user's id differently — the asymmetry Field.user already had, now shared by this population; outside this card.

  5. The export column's name-for-id. referenceFieldNames (export-format.ts) expands a column when meta.type is in its local set and meta.reference is set; the user field without reference now satisfies both, so the export door (rest-server.ts, the expandFields site) expands it like a Field.user column and formatReference renders the user's name, JSON and CSV alike. Right: a change to published output, named under the banner. Pinned twice — the rest unit (['owner', 'assignee'], with amount + reference: 'contracts' excluded as the security(import): an import's reference resolution matches a lookup cell against a target whose exposure refuses reads (census row 7 of #22661) #22739 narrowing says) and the dogfood export leg (owner equals assignee, and assignee is not the id). The second-object read this adds at the export door — sys_user through expand — is the path the Field.user column already rides, the data door's $expand under the security(data, analytics): a lookup target's exposure declaration is not judged when the data door's $expand, or the dataset door's dimension-label pass, reads it — detail withheld pending maintainer #22661 family's decision; this diff adds a column to that path and opens no new one. Not re-measured beyond the dogfood leg, which could not read a name back through a refused expand.

  6. The template wording. describeTemplateColumns takes meta.reference ?? '' as the target and its label from referenceLabels, which the template site in rest-server.ts collects from metaMap.get(f)?.reference. The instructions move from "The name of a record, or its id." (a blank target, double space included) to "The name of a User record, or its id." Right, pinned in import-template.test.ts. The blank-target rendering stays for a lookup without reference — pre-existing, not this card.

  7. The connector pull. connector-pull.ts hands buildFieldMetaMap(schema) to the same runImport over ConnectorPullProtocol (which requires getMetaItem, so the exposure ask fires); a mapped user column now resolves an email to the user's id where the raw email was written as the id before. Right. Measured by the dev through the executor over a stub protocol; carried by no new pin (the existing connector-pull suite, 3 files / 22, is the dev's reading).

  8. plugin-auth's identity import, claimed identical. admin-import-users.ts builds its map through prepareImportRequest → buildFieldMetaMap(schema) for sys_user. Read at the head, sys-user.object.ts declares manager_id: Field.lookup('sys_user', …) and primary_business_unit_id: Field.lookup('sys_business_unit', …) and no user-typed field, so the arbiter's answer for every field of that map is the declared string, byte-identical to main's. Right, confirmed by a definition read and not only by the dev's count. The security(import): an import's reference resolution matches a lookup cell against a target whose exposure refuses reads (census row 7 of #22661) #22739 residual at that door (its hand-written run protocol omits getMetaItem) is unchanged by this diff and stays the seat's filing decision from that record.

  9. Doors left unmeasured. Every non-test caller of buildFieldMetaMap at the head: import-prepare.ts (the prepare step behind the sync door, the jobs door and plugin-auth), the two rest-server.ts sites (export; the template's label collection), import-template.ts, connector-pull.ts. Each is in the dev's door table: sync and jobs (both personas), export (JSON, CSV), template, connector pull (stub), plugin-auth (count; ①.8). The dry run takes the same runner and the same resolveRef. None unmeasured at the level of a door; what is probe-measured without a pin at the head is named in ①.4 and ①.7.

  10. Pins and ablations. Core: the security(import): an import's reference resolution matches a lookup cell against a target whose exposure refuses reads (census row 7 of #22661) #22739 control block becomes four cases (the map's four answers; the email on both fields with sys_user asked once; the unserved control). Rest: two units. Dogfood: both doors × both fields plus export, on a real boot, with the Field.user legs as controls. A1 (the fix reverted, dist preflight proven absent, 3 + 2 + 3 red with every Field.user leg green), A2 and A3 are the dev's measurement, not re-run here; what this record judges is the pins' construction — they cannot pass with the arbiter unasked (A1's reds are the three owner legs) or with the exposure ask removed (A2's reds include the new control). The triage's three pins are all present.

  11. Reach. Stated on a real boot at base 9f5eca52b3 against ce23f40ae8, both personas, on fixture objects; the population the triage named (the AI builder's { type: 'user' } on cloud) is the card's reason and is not measured here; deployed definitions NOT MEASURED. The bound is stated, not hidden.

② Semver level

  • .changeset/22785-import-user-field-target.md: @objectstack/core minor, fix(core)!, the **BREAKING** banner naming both moved populations (the hidden-user import row; the export column's reader), the door-by-door before and after, an unchanged list, and a fix paragraph. Level right: a narrowing is breaking and the launch window ships breaking as minor (check-changeset-no-major, exit 0 at the head; Check Changeset concluded success on both runs). The other touched packages publish nothing from this diff: rest (two tests), dogfood (private). @objectstack/rest and @objectstack/service-automation change no source and owe no changeset of their own — the precedent's reading, applied again, with its cost named: the export and template doors are rest doors whose published output moves, and a rest consumer reads of it only through the dependency-bump line.
  • Clause-②: yes (narrowing) on line 2 of the PR body and in the changeset. Arm and value right. yes: the import door's accept set widens, the card's class, at least minor. (narrowing): one population moves from stored to refused, and the arm is what the gate takes breaking-ness from — the mixed-arm spelling the precedent recorded (22663, a narrowing of this family with a widening alongside, yes (narrowing)). The seat's ruling (arm B) is applied in full: title bang, banner, marker, body line, and the gate reads it as [BREAKING+bang+clause-②-narrowing].
  • The ADR-0087 disposition, not-required (no-migration-prescription), judged on the checker's own stated meaning in three parts.
    • (a) What the refusal at d5798c5afc read. Branch 1 of findMigrationPrescription: the placeholder on the line **FROM → TO, door by door.** Measured on …, which labelPositioned reads as a label (not a heading; no colon after it; the character to its left is *, not a governing letter), so the hit stood on the label's position alone and carriesConcreteRewrite was never consulted. Evidence from-to-label, exactly as the dev reported.
    • (b) Whether the body carries a prescription in the checker's stated sense — "instructions for rewriting a consumer's code". The relabelled section's bullets are behaviour before and after, per door, in the indicative: what the door answered, what it answers now. That is the accept-set FROM → TO the Clause-② path asks a narrowing to state (the triage, the claim and the precedent's own ② all use those words), not a rewrite of anything an author writes or a compiler reads: no spec key, spelling, export, stored shape or exported declaration moves — ExportFieldMeta.reference?: string and buildFieldMetaMap(schema: unknown) keep their signatures, verified at the head — so objectstack migrate meta has nothing to transform and the author's { type: 'user' } stays valid as written. Every factual claim in the marker holds. The fix paragraph: "none is needed" for visible ids; a hidden-id pipeline "runs as a caller who can see them" (an identity, not a code change); and a reader needing the stored id "takes it from the record read … not from the export" — the one sentence that tells a consumer to read elsewhere. That sentence is of the kind the merged 22737-relation-condition-target-exposure.md carries under an explicit **Migration.** label beside this same marker ("Filter on the lookup's stored id … or remove the condition. To keep the condition, declare list …"), which the gate passed and the precedent review judged right for this family ("the fix(metadata-protocol,service-analytics)!: a read that follows a lookup asks the target object its declared exposure — $expand and the dataset label passes (#22661) #22735 / security(data): a nested-relation filter condition on a lookup target is evaluated without asking the target's exposure (census row 4 of #22661) #22737 shape"). So: the refusal was a label-position hit on a behaviour description; the content under the label is the Clause-② statement, not a rewrite mapping; and the relabel removed or hid nothing — the fix paragraph was never the evidence and stands at the head, visible, with the bullets verbatim. The disposition is honest, and the relabel cleared no true signal. Verdict unaffected.
    • (c) Whether another category is honestly available. unpublished: no, @objectstack/core publishes. already-registered: no id exists. registered: no ADR-0087 id is touched, and the one registered route that needs no metadata surface is a D3 semantic ledger entry — the shape 17.export-field-meta-constraints-retired.ts took for an earlier change to this same map, where a published interface lost keys and the ledger was "the only channel that reaches an upgrader"; here no declaration changes, the CHANGELOG carries the full before and after, and neither security(data): a nested-relation filter condition on a lookup target is evaluated without asking the target's exposure (census row 4 of #22661) #22737 nor security(import): an import's reference resolution matches a lookup cell against a target whose exposure refuses reads (census row 7 of #22661) #22739 took that route for this family. runtime-interface-only / type-surface-only: no exported declaration narrows or concretes. So no-migration-prescription is the one category whose facts hold, and it is the family's merged spelling. The residual, for the seat and above one PR: the checker's prose sentence, read strictly, would catch the security(data): a nested-relation filter condition on a lookup target is evaluated without asking the target's exposure (census row 4 of #22661) #22737 **Migration.** paragraph on main today as well; carried to ③.

③ Boundary flags

  • Round-1 open_questions (the arm): ruled by the seat (arm B), applied in round 2, judged right in ②. Round-2 open_questions: none.
  • Round-1 deviations. (1) base moved, two main merges: no effect, this record judges the net diff from the merge-base; the landing fact is below. (2) the carrier guard kept: right (①.2). (3) the arm: ②. (4) the refused first A1 attempt (the tool's own substring refusal), re-run as a delete: process note; the ablation is the dev's measurement. (5) the void first hidden-user reading (a misspelled probe call), re-measured as A1b: process note, and the reason ①.4 names the narrowing as probe-measured and unpinned — one pin owed, non-blocking: a member leg pasting a hidden user's id on owner and on the Field.user control, both answering reference_not_found, which would also pin the control's existing answer for the first time. (6) zero label writes: right, a changeset exists. (7) the dogfood pin inside the claim's conditional surface, no packages/rest source, no toFailedResult, no packages/types: right — the file list bears it out (two rest test files, one dogfood test file, one core source, one core test, one changeset).
  • Round-2 deviations. (1) the relabel: ② (a)–(b). (2) the title bang in the same issue_patch: fine, one write. (3) main not merged, fast-forward 272f15bd62 → 7fffd20e84: confirmed from the commit list. (4) the bot labels (documentation, size/m, tests, tooling) not the dev's: right.
  • The line count. 252 changed lines (+223/−29) against the 250 suggestion, the two over being the banner and marker the ruling required; source +9/−6 against 30. A suggestion, met in substance; non-blocking.
  • The Clause-② / ADR-0087 vocabulary collision — escalated to the dispatching seat as a filing decision; it does not hold this PR. The fleet tells a narrowing changeset to "state FROM → TO" (the post-task checklist, the triage, the claim, the precedent's ②), and check-adr-0087-registration reads those tokens as the migration template: a label-positioned FROM → TO is a hit on its own, and carriesConcreteRewrite's vertical-block arm (VERTICAL_FROM_RE / VERTICAL_TO_RE) reads a - FROM: bullet followed by a - TO: bullet — the exact shape the relabelled section's bullets still have — as a concrete rewrite, so any governed mention of the placeholder anywhere in this body would have been refused too. Every narrowing changeset in this family that titles or structures its before/after with the fleet's own words meets this refusal, and the passing form is to avoid the token — the outcome the checker's check-adr-0087-registration reads a heading that DENIES a migration prescription as evidence of one, so not-required (no-migration-prescription) is unclaimable by the case it names #17357 docblock names as the wrong one ("the only passing form was to avoid a word … a gate shaping prose a human reads"). Either the Clause-② doctrine names a spelling for the accept-set statement the detector does not read as a prescription, or the detector learns the Clause-② section; a card is the seat's to file, with the ② (c) residual riding it: the checker's prose sentence, read strictly, catches a remedy paragraph the fleet admits beside this marker on main.
  • Landing facts, flagged for the owning seat (they do not move this verdict on this head). origin/main is e0785066ea at this read, nine commits past the merge-base 098481744f (the PR body's 23419bafb7 is four behind it), none touching this PR's six files; one, feat(plugin-security,rest,spec)!: retire the permission-set overlay discard (ADR-0131 cutover stage 7-pre) #22776 (feat(plugin-security,rest,spec)!), edits rest-server.ts at two hunks near lines 12353 and 12448, far from the export and template sites this diff's doors live at (near 9723 and 10029), and git merge-tree --write-tree against origin/main merges clean. The PR is draft, mergeable_state: blocked, auto-merge unarmed; the PR body's origin/main sentence is four commits stale — a body fact, no head.
  • Check-runs: five in progress at this read (named above). The derived gates among them are the dev's local readings until they conclude — 68 derived, 68 run, 0 UNRUN at 272f15bd62, byte-identical code at the head. check-adr-0087-registration runs inside Check Changeset (pr-automation.yml: --self-test, then --base the merge-base), which concluded success on both runs at the head, so the gate's own verdict on the relabelled changeset is a check-run, not only the dev's run; the dogfood gate and its three shards concluded success, so the new dogfood pin's real-boot legs are a check-run too.
  • Governed surfaces: none of the six paths hits a GOVERNED_SURFACES row; Governed Surface Queue Guard concluded success. This record is the Clause-② contract-tier review the narrowing owes before the queue, not a Tier S landing record.
  • The card's Blocked-by: #22739: present in the body and the triage, and cleared — security(import): an import's reference resolution matches a lookup cell against a target whose exposure refuses reads (census row 7 of #22661) #22739 landed as c74d843997, which the claim records, so the ordering the card relied on held: the newly admitted target is asked its exposure (①.3).

Implemented-by: claude/issue-22785-import-user-field-target
Reviewed-by: session_01JfJfBUC3cQ6hhgm9MQK76T

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 10:16
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 10:16
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit b7cd1af Oct 11, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22785-import-user-field-target branch October 11, 2026 10:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

import: a user field written without reference fails reference_not_found on import, while the spec's arbiter gives it sys_user

2 participants