Repository navigation
fix(objectql)!: a user field's declared deleteBehavior takes effect when the user is deleted (#29253) - #29368
Conversation
…en the user is deleted The referential delete walk now admits a user field that declares a deleteBehavior; a user field that declares none stays outside it, so a bare one is left as it was. Claude-Session: https://claude.ai/code/session_01ADHkmPJSWAxwHWeAsqirug Co-authored-by: Claude <noreply@anthropic.com>
…delete Claude-Session: https://claude.ai/code/session_01ADHkmPJSWAxwHWeAsqirug Co-authored-by: Claude <noreply@anthropic.com>
…deleteBehavior Claude-Session: https://claude.ai/code/session_01ADHkmPJSWAxwHWeAsqirug Co-authored-by: Claude <noreply@anthropic.com>
…arget helper Claude-Session: https://claude.ai/code/session_01ADHkmPJSWAxwHWeAsqirug Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 20 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 af739b179eae0b0e3faf4dac464144a785dbf826 && git checkout af739b179eae0b0e3faf4dac464144a785dbf826
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eef116e4d1dd3b01d68371df4f775b0c77227ef0 4a0ed134f4d43b1fd9079c2cb3e60e27b1d70a02 && git checkout -B drift-repro eef116e4d1dd3b01d68371df4f775b0c77227ef0 && git merge --no-ff 4a0ed134f4d43b1fd9079c2cb3e60e27b1d70a02
node scripts/docs-audit/affected-docs.mjs --json eef116e4d1dd3b01d68371df4f775b0c77227ef0
|
…arrowing Claude-Session: https://claude.ai/code/session_01ADHkmPJSWAxwHWeAsqirug Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmPJSWAxwHWeAsqirug Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 Contract-review-claim: session_01ADHkmQKwKFCp1m6Pb6Q8c1 · head 4a0ed13 · 2026-11-02T14:07Z The narrowing face is reviewed at |
|
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 Contract reviewServed-tier: Inputs read: card #29253 (body, triage 6575476068, claims 6575764728 / 6575797254 / 6575850669 / 6575856999, os-dev-report 6578157141), PR #29368 (body, file list, net diff Check-runs on this head: every run is ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentThe narrowing against the published spec on
Findings:
No must-fix. The tree matches the PR body's account on every point checked; nothing was judged from the account. Implemented-by: VERDICT: PASS |
|
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 维护者速读
席位意见:契约复审 PASS(本 PR 上的记录,head 席位核对ACCEPT ·
|
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1
Requested by Steve · project thread
Fixes #29253
Clause-②: no (narrowing)
Metadata-protocol: semantic
Skills-docs: follow-up #29400 — skills/objectstack-data/rules/field-types.md (the
userrow lists nodeleteBehavior), skills/objectstack-data/rules/relationships.md;content/docs/data-modeling/fields.mdxis updated hereBREAKING for an app that declares
deleteBehavioron auserfield: the declared value now takes effect when the user is deleted. A delete of a user held by arestrictfield (or arequiredfield declaringset_null) is now refused with409 DELETE_RESTRICTEDwhere it succeeded; a declaredcascadenow deletes the holding rows; a declaredset_nullnow clears them. The fix: if you declareddeleteBehavioron auserfield only as documentation, remove it or change it to what you want enforced.维护者速读
FieldSchema.deleteBehaviordefaultsset_nullonto EVERY field type, so built artifacts ship the key ontext/datetime/numberfields #9784 之前存下的对象可能在 user 字段上带着自动写入的 set_null,现在也会生效。仓内平台对象和示例里没有这样的字段。回滚即 revert 本 PR。What changes
Measured first: which doors delete a user (at
origin/mainb1779200):ObjectQL.delete(so the walk runs)?internalAdapter.deleteUserdeletes theusermodel through the plugin-auth adapter:objectql-adapter.tsdelete/deleteManycalldataEngine.delete(objectName, { where: { id } }), anddataEngine.deleteise.delete(m, asAttributedSystem(q))(isSystem: true). The request runs insiderunSubjectErasureAtomically(SESSION_ERASURE_PATHS), so a refusal rolls the credential rows back.DELETE /data/sys_user/:id, MCP / batch data tools)identity-write-guard.tsregisters abeforeDeletethat refuses a caller-context write to anymanagedBy: 'better-auth'object (ADR-0092 D2);sys_userdeclares no generic delete (userActions: { edit: true }only).admin-ban-endpoints.tsupdates the user and deletes its sessions; thesys_userrow stays.Every door that removes the
sys_userrow reaches the walk, so this is (B) enforce, and no spec change.The change (
packages/objectql/src/engine.tsonly):statesDeleteSemantics(the one family test every reader of the walk asks) admits auserfield when, and only when, it declares adeleteBehaviorof the enum's three values. Auserfield that declares none stays out: the spec's parse materializes no value on it, and the engine invents no default.deleteWalkTargetOf(new) answers the object a relation points at forcascadeRelationBehaviorandplanCascadeAtomicity. It still reads thereferencecarrier throughreferenceCarrierOf, so an unreadable carrier still throws; for auserfield it answersreferenceTargetOf(sys_user), so a{ type: 'user' }with noreferencestill points at the user.userfield then goes through the walk exactly as alookupwith the same authored value, including the multi-value$containsprobe and member removal, the requiredset_null→restrictescalation, the two-phase cascade set and the one-transaction plan.DELETE_SEMANTICS_REFERENCE_TYPESkeepsmaster_detail,lookup,tree; its docblock's old "userstays out … made in the spec, never here" paragraph is replaced by the declared-only rule above.Docs: one paragraph in
content/docs/data-modeling/fields.mdx(Referential integrity). Changeset:.changeset/29253-user-field-delete-behavior.md,@objectstack/objectqlminor (the level a semantic metadata-protocol kind takes), with the BREAKING banner, the one-line fix, and the ADR-0087 dispositionnot-required (no-migration-prescription): no schema key, export or type moves, and there is no stored value for a conversion to rewrite.Tests
New
packages/objectql/src/engine-cascade-delete-user-field.test.ts(16 tests; the realObjectQLover a stub driver with snapshot rollback, whose WHERE matcher answers$or/$and/$in/$containsand throws on any other operator). Deletes go through the engine with and without the adapter's system context:deleteBehavior(parsed single, parsed multi, raw, required): the user is deleted and the holder keeps the id;restrict, single and multi-valued:DELETE_RESTRICTED/ 409 naming the holder's object, both rows kept; the same code and status alookuprestrict tosys_useranswers; a user nobody holds still deletes; auserfield with noreferencestill refuses;cascade, single and multi-valued: the holders are deleted, a row holding another user stays; a laterrestrictrolls the cascade back;set_null: single cleared tonull; multi loses only the member, an emptied set is[]; requiredset_nullrefuses.Forced test edit:
packages/objectql/src/federated-injected-column-readers.test.ts(the #21918 reader ledger). The tworeferenceCarrierOf()seam uses moved fromcascadeRelationBehavior/planCascadeAtomicityintodeleteWalkTargetOf, so the two oldskipsrows are replaced by twonot-a-readrows for the helper. The two callers' ownisFederatedUnprovisionedInjectedColumn()rows stay and still bind the skip.Acceptance notes
userfield that declaresrestrict, orset_nullwithrequired: true, now refuses deleting the user it holds (409, was 200);cascadenow deletes the holders;set_nullnow clears them. A repo-wide scan ofField.user(/type: 'user'declarations underpackages/andexamples/found none that declaresdeleteBehavior.FieldSchema.deleteBehaviordefaultsset_nullonto EVERY field type, so built artifacts ship the key ontext/datetime/numberfields #9784: the spec's parse then wrotedeleteBehavior: 'set_null'on every field type,userincluded, so an object stored in that era can carry aset_nullon auserfield no author wrote. It now takes effect (cleared on user delete; refused on arequiredfield). Stated in the changeset; the seat accepted it with no spec card.packages/spec/**edit on this card): theFieldSchema.overwritecomment inpackages/spec/src/data/field.zod.tsstill says auserfield sits outside the walk's family; true for a bare one, no longer for an authored one.os verify --delete-behaviorsreports an authoreduserfieldnot-derivable(its targetsys_useris not an app-declared object), so that family does not start probing these fields.Verification
Red first: the pins against the walk as it was (ablation 1)
return false; // ABLATION-29253in place of theuserarm ofstatesDeleteSemantics(the walk onmain):Tests 10 failed | 6 passed (16). Every restrict / cascade / set_null pin fails (five with "expected the delete to be refused"); the four no-deleteBehaviorcontrols stay green.Ablations (scripts/ablation-replace.mjs; restore by git checkout HEAD, git diff HEAD empty, disk blob == HEAD blob 5defa623)
userarm answersfalse(user out of the walk, as onmain):Tests 10 failed | 6 passed (16).deleteWalkTargetOfanswers the carrier only:Tests 1 failed | 15 passed (16), only "a user field naming no reference still references sys_user".userarm answerstrue(every user field joins, a bare one defaulting toset_null):Tests 4 failed | 12 passed (16), the four no-deleteBehaviorcontrols (e.g.expected { person: null } to match { person: 'r_1' }, multiexpected { person: [] }).Code head 7675ff7: tests and gates (origin/main merged once, after #29245 landed)
pnpm --filter @objectstack/objectql test:Test Files 531 passed (531),Tests 10292 passed (10292); thenpnpm --filter @objectstack/objectql typecheck(incl.check:test-typecheck): exit 0 (lock VERDICT command-exit 0). The first full run on the merge head 039625f was1 failed: the reader-ledger test above, fixed in 7675ff7.src/engine-cascade-delete-user-field.test.ts:Tests 16 passed (16);src/federated-injected-column-readers.test.ts+src/engine-cascade-federated-tenant-anchor.test.ts:Tests 17 passed (17); fix(objectql): an update carrying a stored reference back unchanged is not refused because it stopped resolving (#29062) #29245'ssrc/engine-lookup-referential-integrity.test.tsruns in the full suite above.node scripts/pm/dispatch-gates.mjs --commands(no paths): 93 families derived; all 93 run, each exit 0;--ran: 93 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero). Includescheck:where-matcher,check:objectql-double-limit,check:engine-double-contract(none new).packages/objectqlwith--no-inline-config: exit 0. Repo-widepnpm lintis CI's.Round 2 head 4a0ed13 (changeset and declaration only; code unchanged from 7675ff7)
pnpm --filter '@objectstack/dogfood^...' build, lock VERDICT command-exit 0), thenpnpm --filter @objectstack/dogfood test(no shard, no path): lock VERDICT command-exit 0 ·Test Files 392 passed | 3 skipped (395)·Tests 3227 passed | 28 skipped (3255)· no failures.node scripts/check-adr-0087-registration.mjs --base origin/main: exit 0, 1 declared-breaking changeset (banner, bang and narrowing arm all read), dispositionnot-required (no-migration-prescription).node scripts/check-changeset-no-major.mjs --base origin/main --event(this body as the pull_request payload): exit 0, level axis reads the narrowing arm and the semantic kind, no moved package graded patch.node scripts/pm/dispatch-gates.mjs --commands(no paths): the same 93 families; all 93 run, each exit 0;--ran: 93 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero).