fix(objectql): the cascade skips every injected column a federated object does not provision - #21937
Conversation
… provision The cascade scan, its atomicity plan and the lifecycle tenant partition now ask one predicate, isFederatedUnprovisionedInjectedColumn, which reads the registry's own provenance (resolveInjectedColumnProvenance) instead of naming organization_id alone. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…ery injected anchor, and the lifecycle partition Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…e business-unit and user delete doors Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…derated-injected-anchors
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 17 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 2ab3b0073b37a2d709d5d58ef4183628665f8ef3 && git checkout 2ab3b0073b37a2d709d5d58ef4183628665f8ef3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9dce635337c2cc42a4149aa49289ad77d172363d 85098a49af592a66fec2048e93c0fc97186905dc && git checkout -B drift-repro 9dce635337c2cc42a4149aa49289ad77d172363d && git merge --no-ff 85098a49af592a66fec2048e93c0fc97186905dc
node scripts/docs-audit/affected-docs.mjs --json 9dce635337c2cc42a4149aa49289ad77d172363d
|
ACCEPT (seat review) — PR #21937 at head
|
Each of its three scanning tests re-parsed the package source and climbed from every node to the root to name its site: about 1.5 s per scan unloaded. Under the CPU contention a Test Core shard runs at, two of them measured past vitest's 5 s default timeout. The walk now hands the enclosing site down, the scan is computed once per run, and the tests that may pay for it declare an explicit budget. The READERS table is unchanged, and the pin's own exact-set assertions hold against it. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
ACCEPT (seat review, head moved) — PR #21937 at head
|
Fixes #21918
Clause-②: no
What was wrong
Deleting a record runs the engine's referential cascade (
ObjectQL.cascadeDeleteRelations), which probes every registeredlookup/master_detailfield that references the deleted object. The registry injects its own columns into every object, federated (ADR-0015external) ones included: the tenant anchororganization_id, the ADR-0117 D1 anchorowning_business_unit_id, the ownerowner_id, and the audit lookupscreated_by/updated_by. The platform provisions no storage for a federated object, so none of them exists on the remote table.PR #21917 (for #21910) taught the scan to skip
organization_idalone, throughisFederatedInjectedTenantAnchor. The scan still probed the remote table on the other anchors. The SQL driver refused the unknown column (INVALID_FILTER), the probe's catch propagated it as #8895 rules, and the delete failed:DELETE /api/v1/data/sys_business_unit/:idanswered 400 (INVALID_FILTERonshowcase_ext_customer.owning_business_unit_id);showcase_ext_customer.created_by, raised inside better-auth).What changed
packages/objectql/src/federated-object.ts:isFederatedInjectedTenantAnchoris replaced by one general predicate,isFederatedUnprovisionedInjectedColumn(schema, fieldName). It isisFederatedObject(schema)andresolveInjectedColumnProvenance(schema, fieldName) === 'injected-unprovisioned', the registry's own [Decision]applySystemFieldsinjects platform anchors intoexternalobjects the platform provisions no storage for — three consumers have now independently re-derived "that column is not really there" #7865 provenance. It names no column. TheisFederatedObjectconjunct changes no verdict (the provenance only answersinjected-unprovisionedon anexternalobject). It comes first so a local object answers without deriving its injection plan, since the cascade asks this for every relation on every delete. The file is not re-exported from the package entry.packages/objectql/src/engine.ts:cascadeDeleteRelationsandplanCascadeAtomicityask the general predicate at the same place finding(objectql): deleting an organization answers 500 when a federated object is provisioned, because the cascade scan probes the remote table on the platform-injected organization_id #21910 put the tenant-only one, so the two still agree. ⛔ The probe's catch is not widened. A lookup the author declares on a federated object, including an author's ownorganization_idorowner_id, answersauthorand stays in the scan with ObjectQL.cascadeDeleteRelations fails OPEN: a failed dependents probe skips therestrictguard entirely, so a delete that should be refused succeeds silently #8895's propagate disposition.packages/objectql/src/lifecycle/lifecycle-service.ts: the reap and archive passes now get their per-tenant windows from one shared helper,tenantWindowsFor. It returns no windows for an object whoseorganization_idis a federated unprovisioned injected column. Measured: the spec accepts alifecycleblock besideexternal(retention, ttl and archive all parse), so the triage ruling brings these passes in scope. Such an object's rows carry no organization, so it has no tenant partition. It runs its one global pass, which is the window a provisioned object's no-organization rows get from the same$orarm. Before this change, every partitioned pass was refused as an unknown column, and the object's sweep failed before its global pass ran..changeset/21918-federated-injected-anchors.md:@objectstack/objectqlpatch,Clause-②: no.The producer side is ruled (#7865 direction B keeps the injection and supplies the marker this reads), so the fix stays in the engine's readers. No
packages/specedit.The enumeration pin (the closing act)
packages/objectql/src/federated-injected-column-readers.test.tsscans every non-test source of@objectstack/objectqlwith the TypeScript parser for every use of a named seam:referenceCarrierOf,referenceTargetOf);The names are not listed. They come from
injectedSystemColumnDefs(@objectstack/spec/data), the table the registry spreads. Each use is keyedFILE#FUNCTION :: SEAM, and every key must have a row in a closed-disposition table, while every row must still be found. A row that says the site asks a federated predicate is checked against the source: the site calls it, or calls the named same-file helper that does. Why a scan and not a registry the readers call into: the readers that failed in this family did not know the question existed, so they would never have registered. A scan finds them by the seam they cannot avoid.The 63 seam uses today, by disposition:
cascadeDeleteRelations,planCascadeAtomicity, lifecycletenantWindowsFor(andreap/archiveObjectthrough it)isFederatedObject)buildDriverOptions, the related-record read (resolvePredicateRelated),resolveSystemInsertOrganizationauditableReferenceFields,organizationFieldOf)eventOrganizationIdreads the written row; a federated row has no tenant column, so the key is omittedassertReferencesResolve,expandRelatedRecords,resolveRelatedTitleTargetbuildSummaryIndex(a roll-up's FK inference; see Acceptance notes)created_at(the age a retention / archive selects by)created_by/updated_bystampingPins
packages/objectql/src/federated-object.test.ts(5): the general predicate accepts every injected anchor of a registered federated object. It agrees withunprovisionedInjectedColumnson every field of three objects. It refuses an author-declaredorganization_id,owner_idand lookup (author), and every injected column of a local object (injected-provisioned). It also refusesidand inputs that are not objects.packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts(finding(objectql): deleting an organization answers 500 when a federated object is provisioned, because the cascade scan probes the remote table on the platform-injected organization_id #21910's 5 pins kept, 7 added, throughengine.deleteon a two-driver engine; the existing stub driver now also logs which columns each read filters on):owning_business_unit_idIS probed (control);owner_id/created_by/updated_byARE probed;unit_refandowner_idon a federated object are still probed, and only those columns are. Their failure propagates with its envelope (INVALID_FILTER, 400, the same error object);splitwith one warning.packages/objectql/src/lifecycle/lifecycle-service.test.ts(4 added): a federated object's reap and archive run one global pass and never filter onorganization_id(the double refuses any read naming it). The same declaration on a local object, and anorganization_idthe author declared on a federated object, keep their per-tenant partition.packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts. They boot the showcase withorgContext, provision the fixture withonEnablein the test's ownmkdtempdirectory, and assert the premises on the same boot: the remote rows are served, each anchor isinjected-unprovisioned, and a SYSTEM read filtered on each one is refusedINVALID_FILTER. Then:DELETE /api/v1/data/sys_business_unit/bu_21918answers 200, the row is gone, and the federated rows are untouched;POST /api/v1/auth/admin/remove-useranswers 200, the user row is gone, and the federated rows are untouched.organization-delete-federated-fixture.dogfood.test.ts) stays green.The user-delete door, and a harness gap
The only HTTP door that deletes a user is better-auth's
POST /api/v1/auth/admin/remove-user, whichplugin-authmounts when the better-auth admin plugin is on.DELETE /data/sys_user/:idanswers 405 by design (ADR-0092), and/auth/delete-useris unconfigured (404).objectstack serveturns the admin plugin on by default (OS_AUTH_ADMIN,packages/cli/src/commands/serve.ts). The verify harness constructsAuthPluginwith no plugin options, so the route answers 404 there. That is the 404 finding(objectql): deleting an organization answers 500 when a federated object is provisioned, because the cascade scan probes the remote table on the platform-injected organization_id #21910's dev measured.OS_SCIM_ENABLED, the one switch the harness reads that does (ADR-0134), the same knobadmin-credential-lifecycle.dogfood.test.tsuses.sys_user.role === 'admin'scalar, which ADR-0068 D2 retired. So a platform admin is refused there with 403YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS, a ruled state.plugin-auth'sremove-user-atomicity.test.tsdoes, because its subject is the cascade and not the route's authorization.Measured readings (showcase with the federated fixture, own temp dir)
f243a29290)DELETE /data/sys_business_unit/:idINVALID_FILTERonowning_business_unit_id; log[sql-driver] INVALID_FILTER ... showcase_ext_customer ('owning_business_unit_id')POST /auth/admin/remove-user(admin plugin on, legacy scalar)INVALID_FILTER ... ('created_by'), better-authSERVER_ERROR; user row survivesYOU_ARE_NOT_ALLOWED_TO_DELETE_USERS(ruled, unchanged)Atomicity plan (
planCascadeAtomicityon the booted showcase): organization, business unit and user all readsplitbefore andatomicafter. The only non-default-driver participants were the two federated objects, reached through injected anchors. Scan and plan agree, measured after: the set of objects the scan probed (its[reference-cleanup]record, filed once per probed child) equals the plan's first-level participant set: organization 53 = 53, business unit 27 = 27, user 66 = 66, with no federated object in any.Reverse verification (committed HEAD
1a131e4b4b, every mutation throughscripts/ablation-replace.mjs)fieldName === 'organization_id' &&put back in front, anchor 1 to 0, bloba432c5754eb4tocccef63025f7). After rebuilding@objectstack/objectql,ablation-dist-preflightfound the marker in 4 built files.expected 400 to be 200(INVALID_FILTER), and the user removalexpected 500 to be 200.a432c5754eb4equals HEAD andgit diff HEADis empty. After a rebuild, the marker is absent from all 14 built files, andgit status --porcelainis empty.referenceTargetOfplanted ineventOrganizationId): the enumeration pin goes red, naming the unlisted keyengine.ts#eventOrganizationId :: referenceTargetOf(). Restored to the HEAD blob.1b78524d3f29.src/.Gates (at
7c2888a239, after mergingorigin/mainfaf8dce482)node scripts/pm/dispatch-gates.mjs --commandsover the branch derived the same 71 commands as the dispatch. All 71 exit 0, and--ranreads71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN.check:dual-build-cjs-loadsfirst answered exit 3PREREQUISITE NOT MET(8 packages had nodist/). After building them it passes (106 entry points across 66 packages).check-closing-target-claim.mjs,check-partof-closing-keyword.mjsandcheck-single-claim-paths.mjsneed a PR in their environment (exit 2, NOT WIRED before this PR existed); their CI workflows run them.check:adr-symbol-anchors,check:scripts-symbol-anchors,check:spec-docblock-symbol-anchors,check:adr-anchors: all exit 0.check:objectql-double-limitpasses: no new double. The existing stub driver was extended, and itsfindstill applies the caller'slimitafter the filter.pnpm --filter @objectstack/objectql test(two shards): 378 files, 7495 tests passed.pnpm --filter @objectstack/objectql typecheck:tscclean, andcheck:test-typecheckreads OK with no new debt (234 ledgered errors, none in the four touched test files, whichtsc --listFilesincludes).pnpm --filter @objectstack/dogfood typecheck: exit 0, and it includes the door pin.eslint.config.mjslints**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}outsideNEVER_LINTED;--format jsonreports 8 files, 0 errors and 0 warnings;parserOptions.project, no typed rules), so this diff moves no untouched file's verdict. The fullpnpm lintis CI's.Acceptance notes
buildSummaryIndex(dispositionauthor-declared, not changed here). A roll-up declared with norelationshipFieldinfers its foreign key from the child's first relation to the parent. Injected anchors point only atsys_organization,sys_business_unitandsys_user, so it can meet one only for a roll-up declared on one of those, over a federated child. Inference, unmeasured.plugin.ts#registerAuditHooksstampscreated_by/updated_byon insert and update, federated objects included). A write to a writable federated datasource would carry columns the remote may lack. Inference, unmeasured: the showcase's federated datasource refuses writes.created_atstays the policy's subject. A federated object that declaresretention(orarchivewithoutttl) reaps bycreated_at, which is the registry's injection there. A remote without it refuses the filter, and the sweep reports the object inerrorsevery sweep. The spec accepts the declaration and lint does not warn.AuthPlugindiffers fromserve.ts's default (adminon), so vendor admin routes answer 404 under the harness unless a test setsOS_SCIM_ENABLED.Generated by Claude Code