Repository navigation
feat(lint,spec): warn on a user field whose reference names an object other than sys_user (18.x loud half; refusal is the next major's) - #24942
Conversation
… other than sys_user WIP: the 18.x loud half under the standing ruling; no ADR-0087 conversion carries the refusal. Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude <noreply@anthropic.com>
…plain entry Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 143 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 809fc5148dfc0010a09f0144757cb0c771f369c4 && git checkout 809fc5148dfc0010a09f0144757cb0c771f369c4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 71696121a64d8bd057fac969a72a84295890ed37 38154076205504a920477219e862fd5a5b1630df && git checkout -B drift-repro 71696121a64d8bd057fac969a72a84295890ed37 && git merge --no-ff 38154076205504a920477219e862fd5a5b1630df
node scripts/docs-audit/affected-docs.mjs --json 71696121a64d8bd057fac969a72a84295890ed37
|
Contract reviewServed-tier: Reviewed at 2026-10-20T17:27Z · card #24808 (body and every comment, the claim ① Derived judgmentsThe diff implies the following accept-set and public-surface changes. Each is named right or wrong against the card's ruling (A on #24854, timed by 「甲」).
The 18.x shape honours 「甲」: no silent narrowing (items 1, 3) and no silent runtime re-target (items 2, 3). ② Semver levelChangeset
③ Boundary flagsDev flags and
Implemented-by: VERDICT: PASS |
Closes #24808
Clause-②: yes (widening)
The claim's
Clause-②: noline was provisional. It is reset here, and in the changeset, from the measurement below. Under 「甲」 no conversion carries the refusal, so this PR is warning-only: no accept set narrows. It does publish one new rule id constant and itsos explainentry from@objectstack/lint, and that is a widening of the public surface. That follows the lint precedent, where publishing rule-id constants was declaredyes (widening). The changeset isminorfor@objectstack/lintandpatchfor@objectstack/spec.What this does
@objectstack/lint: new advisoryuser-field-foreign-reference. It fires on auserfield, on an object or an object extension, whosereferencenames an object other thansys_user. It is a warning onos validate,os buildandos lint(through the reference-integrity suite in the authoring registry), so no command's exit code changes. The hint is the canonical spelling:{ type: 'lookup', reference: 'TARGET' }, or dropreference.USER_FIELD_FOREIGN_REFERENCEis exported from the barrel, andRULE_EXPLANATIONSgains its entry.@objectstack/spec:Field.user()'s config typesreferenceas@deprecated. The accept set and the output are unchanged. The docblocks onField.user()andIMPLICIT_REFERENCE_TARGETSnow say what is declared and what is enforced.lookup:packages/lint/src/object-graph.test.ts, the module fixtureowner. Two pins of the 18.x resolution are kept on purpose, each with a comment:packages/spec/src/data/field-value.test.ts(my_people), and the object-graph "explicit carrier wins" control, which now builds its own inline fixture.Step 1: measuring producers (tree
objectstack-ai/objectstack@b42c621c05)Scan: every object literal with
type: 'user'(TS or JSON quoting, nested-brace-free) whosereferenceis not'sys_user'orSystemObjectName.USER, plusField.user({ … reference … }), plus a window scan of six lines either side of 413type: 'user'lines in 176 files.examples/**(all example apps)Field.user({ reference })callsField.user(lines, none passreference)packages/spec/src/data/field-value.test.ts:91(my_people),packages/lint/src/object-graph.test.ts:39(crm_person)The window scan's other 81 candidates were all neighbouring
lookupfields or unreadable-carrier refusal probes (reference: { object: 'sys_user' }). Read by hand, none is a foreign-targetuser.Step 2: can an ADR-0087 conversion carry the refusal? No
ADR-0087 D2 scopes conversions to "losslessly mappable changes only". Rewriting
{ type: 'user', reference: X }to{ type: 'lookup', reference: X }keeps the stored shape and every reader that follows the target. It changes three behaviours that are measured in this tree:useris outside the referential delete walk.DELETE_SEMANTICS_REFERENCE_TYPESinpackages/objectql/src/engine.tsismaster_detail,lookupandtree.FieldSchema's overwrite materializesdeleteBehavior: 'set_null'onlookup/treeonly (field.zod.ts, theFieldSchema.deleteBehaviordefaultsset_nullonto EVERY field type, so built artifacts ship the key ontext/datetime/numberfields #9784 block). So today, deleting an X record leaves auser(X)column untouched. After the rewrite, it would null that column, andplanCascadeAtomicitywould plan X's deletes differently.deleteBehaviorisset_null | cascade | restrict, and none of these means "do nothing". No rewrite preserves the behaviour.fieldHoldsUsers(plugin-sharing/src/sharing-rule-service.ts) answers true for anyuserfield. After the rewrite it is false for a lookup to X.current_userdefault. It is legal onuserwhatever the reference. Onlookup(X)with X notsys_userit is refused at parse (default-value-shape.ts), so that subset would convert into a refused shape.So under 「甲」 rule 2, 18.x ships the loud half only, and the refusal is filed for 19.0. The follow-up text is in the report for the seat.
Step 3: what was not done in 18.x, and why
referenceTargetOfforuserstill returns the authored carrier. Making it returnsys_userwould silently re-target a field that still loads (expand, import, seed and FK resolution would flip from X tosys_user). That is a behaviour change with no conversion and no refusal in front of it, which is what 「甲」 keeps out of an 18.x minor. It moves with the 19.0 refusal: once parse refuses a foreign carrier, the carrier can only ever besys_user. The dispatch asked for this flip "either way". This PR does not make it, and the report records the deviation as an open question.Field.user()is not type-narrowed toreference?: 'sys_user'. That would be a compile-time refusal of the TS authoring surface with no conversion, so it is a 19.0 narrowing too. Keeping...configlast is deliberate. Movingreference: 'sys_user'after the spread would silently re-target an untyped caller's field. With the spread last, the lint sees the foreign carrier and warns.Tests
Reproduction first:
ObjectSchema.safeParse({ name: 'probe_task', fields: { owner: { type: 'user', label: 'Owner', reference: 'probe_account' } } })succeeds, andreferenceTargetOfanswersprobe_account. The 18.x half pins that this shape still loads.describe('[#24808] …')inpackages/lint/src/validate-object-references.test.ts. It covers the warning (rule, severity, path, where, message, hint), object extensions, the map form, one-line message and hint of at most 200 characters, theos explainentry, and the authoring registry onvalidate/build/lint(advisory, never an error). Controls: noreference,reference: 'sys_user'andField.user()stay silent;Field.lookup('probe_account')and a literal lookup stay silent; an unreadable carrier still throws.node scripts/ablation-replace.mjsdeleted thefindings.push(...userFieldForeignReferenceFindings(stack))line (anchor 1 → 0, blobbc1ff6a17b→3390aeb770), and 7 tests went red. Restored withgit checkout HEAD: the blob matchedHEAD:(bc1ff6a17b) andgit diff HEADwas empty. Lint tests importsrcdirectly, so nodistleg applies.pnpm --filter @objectstack/lint test: 151 files / 7005 tests passed.pnpm --filter @objectstack/lint typecheckandpnpm --filter @objectstack/spec typecheckexit 0.pnpm --filter @objectstack/spec exec vitest run src/data: 132 files / 4169 passed.3815407620, the derivationnode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackgives 88 families. All ran, and--ranreconciles them: 86 exit 0, and 2 are NOT MEASURED. Those two arecheck:dual-build-cjs-loadsandcheck:lean-entry-closure, each exit 3, PREREQUISITE NOT MET: they read a full-monorepodistthis worktree did not build, so they are declared to CI. Also exit 0:pnpm --filter @objectstack/spec check:generated(all 14 artifacts up to date),check:adr-0087-registration(no declared-breaking changeset),check:changeset-no-majorandcheck:nul-bytes. Packageintegrationtiers do not apply: the diff touches no CLI or driver spawn path.Acceptance notes
b434f5f47), and in itpackages/fields/src/__tests__/userCell.readGate-10535.test.tsx:157renders{ type: 'user', reference: 'crm_member' }. That is a renderer fixture, not a metadata producer. Carrier: whoever lands the 19.0 refusal./meta, MCP) object-write leg: the reference suite member runs onflow/datasetsnapshots only. Crossing it ontoobjectwrites is that member's own false-positive measurement (itsruntimeTypesdocblock). Carrier: none.Generated by Claude Code