fix(objectql): the cascade skips a federated object's injected tenant anchor - #21917
objectstack-fleet[bot] merged 3 commits into
Conversation
… anchor The registry injects `organization_id` (a lookup to `sys_organization`) into every object it registers, federated ones included, and the platform provisions no storage for a federated object. The cascade scan probed the remote table on that column, the driver refused the unknown column, and every organization delete answered 500 on the showcase once its federated fixture existed. Both cascade walks now ask one predicate, `isFederatedInjectedTenantAnchor`: the column is `organization_id`, the object is federated by `isFederatedObject`, and the field is the platform's own definition by the injected-column provenance marker. An author-declared `organization_id` and any other author lookup on a federated object stay in the scan, and the probe's catch is unchanged. The atomicity plan asks the same predicate so it keeps the scan's participant set. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…easured On the showcase the plan still reaches the federated object through its injected owning_business_unit_id at depth 1, so its verdict for an organization delete stays cross-datasource. The comments and the changeset now claim only that the plan keeps the scan's participant test. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…er's limit check:objectql-double-limit could not drive the stub's find (its failure lookup threw on the gate's row stub) and refused it as a new unjudged double. The find now reads rows from a table map, applies the bound after the filter by presence, and the gate grades it as applying the bound. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) 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 1f8c7a8dddb33e65a12cc750e14fc29969925454 && git checkout 1f8c7a8dddb33e65a12cc750e14fc29969925454
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e6dc7a240617eaeef9a64e788bf6e5561c107f1b 99eb3665777cdd9c84cd9e6ea485e833ef32a27d && git checkout -B drift-repro e6dc7a240617eaeef9a64e788bf6e5561c107f1b && git merge --no-ff 99eb3665777cdd9c84cd9e6ea485e833ef32a27d
node scripts/docs-audit/affected-docs.mjs --json e6dc7a240617eaeef9a64e788bf6e5561c107f1b
|
ACCEPT (seat review) — PR #21917 at head
|
Fixes #21910
Clause-②: no
What was wrong
Deleting an organization runs the engine's referential cascade (
ObjectQL.cascadeDeleteRelations), which probes every registeredlookup/master_detailfield that references the deleted object. The registry injects the tenant anchororganization_id(a lookup tosys_organization) into every object it registers, federated (ADR-0015external) ones included, and the platform provisions no storage for a federated object. The scan read that injected field as a real reference and probed the showcase's remotecustomerstable onorganization_id. The SQL driver refused the unknown column (INVALID_FILTER), the probe's catch propagated it as #8895 rules for a missing column, and the organization delete answered 500.What changed
packages/objectql/src/federated-object.ts: a new predicate,isFederatedInjectedTenantAnchor(schema, fieldName). It is true only when all three hold: the field isorganization_id; the object is federated byisFederatedObject, the predicatebuildDriverOptionsand the related-record read already ask; and the injected-column provenance marker (resolveInjectedColumnProvenance, the [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 ruling) answersinjected-unprovisioned. Anorganization_idthe author declared answersauthorand stays a relation.packages/objectql/src/engine.ts,cascadeDeleteRelations: the scan skips a field the predicate accepts, right after the reference match and before the elevation record and the probe. The probe's catch is unchanged. It is NOT widened to pass a missing column as benign.packages/objectql/src/engine.ts,planCascadeAtomicity: the atomicity plan asks the same predicate. That method's own comment requires its participant test to be the scan's ("so the two cannot disagree about who participates"), and a scan-only change would have made that sentence false. This is a bounded in-place fix, named here with its evidence below..changeset/21910-cascade-federated-tenant-anchor.md:@objectstack/objectqlpatch,Clause-②: no.The fix is in the scan, the consumer of the injected anchor. The producer side is ruled: the #7865 ruling (direction B) keeps the injection for
externalobjects and supplies the provenance marker this predicate reads. Nopackages/specedit.Pins
packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts(5 tests, a two-driver engine, all throughengine.delete):organization_id, so the organization delete lands while that read would be refused. A local object's injected anchor IS read on the same delete (control);organization_idthe author declared on a federated object is still probed, and the probe's failure propagates with its envelope (codeINVALID_FILTER,status400, same error object);org_ref) is still probed, and its failure propagates the same way;packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts: boots the showcase withorgContext, provisions the federated fixture withonEnablein its ownmkdtempworking directory, and asserts its premises on the same boot. The remote rows are served, the injected anchor answersinjected-unprovisioned, and a SYSTEM read filtered onorganization_idis refusedINVALID_FILTER. Then the owner'sPOST /api/v1/auth/organization/deleteanswers 200, the row is gone, and the federated rows are untouched. It neither reads nor writespackages/qa/dogfood/.objectstack/data/showcase_external.db: after the runs that directory does not exist in this worktree.restrictguard entirely, so a delete that should be refused succeeds silently #8895's pins stay green:engine-cascade-delete-probe-failure.test.tswith the rest of the cascade files (8 files, 90 tests), and the whole@objectstack/objectqlsuite (375 files, 7469 tests).Measured readings
e6dc7a2406, the door pin):expected 500 to be 200. The server log shows[reference-cleanup] referential integrity check on 'showcase_ext_customer' ... relationField organization_id, then[sql-driver] INVALID_FILTER — a WHERE column could not be resolved on 'showcase_ext_customer' ('organization_id'),Delete operation failed, better-authSERVER_ERROR, and[AuthPlugin] ... HTTP 500.onEnablenot run, the federated reads dropped): 200. The probe onshowcase_ext_customerfails withno such table: customers, and the probe's missing-table branch passes it.99eb366577): the door pin and the unit pin are green.Cascade delete of 'sys_organization' cannot run as one unit of work. A walk of the plan's closure on the booted showcase shows why. At depth 1 the plan reachesshowcase_ext_customerandshowcase_ext_orderthrough their injectedowning_business_unit_id(a lookup tosys_business_unit, which is itself at depth 0). So on the showcase the plan change moves no verdict. It keeps the plan's participant test equal to the scan's, which the unit pin and the reverse verification below measure.Reverse verification (on committed HEAD
99eb366577)scripts/ablation-replace.mjs(anchor 1 to 0, blob2073a1d4b84dto03dfd65888d8). Then@objectstack/objectqlwas rebuilt, andablation-dist-preflight --absentconfirmed the marker is absent from all 14 built files. Unit pin: 1 failed, 4 passed (the scan test). Door pin:expected 500 to be 200.2073a1d4b84dequals HEAD andgit diff HEADis empty. After a rebuild, the preflight in present mode found the marker in 4 built files, with the tree clean.2073a1d4b84dto351409525155. Unit pin: 1 failed, 4 passed (the plan test). The unit pin imports./engine.jsrelatively, so it reads source, neverdist/. The restore proved blob equals HEAD,git diff HEAD0 bytes, and an empty porcelain.Gates (at
99eb366577)node scripts/pm/dispatch-gates.mjs --commandsover the branch derived the same 71 commands as the dispatch. All 71 exit 0, and--ranreports71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN.check-closing-target-claim.mjs,check-partof-closing-keyword.mjsandcheck-single-claim-paths.mjs. Their CI workflows run them.check:adr-symbol-anchors,check:scripts-symbol-anchors,check:spec-docblock-symbol-anchorsandcheck:adr-anchorsall exit 0.check:objectql-double-limit: the new stub driver'sfindapplies the caller'slimitafter the filter, by presence. The gate grades it as applying the bound (452 graded, 255 apply, none new).pnpm --filter @objectstack/objectql typecheckandpnpm --filter @objectstack/dogfood typecheckare green, andtsc --listFilesincludes both new test files.eslint.config.mjslints**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}outsideNEVER_LINTED, so the changeset is not in it;--format jsonreports 4 files, 0 errors and 0 warnings;parserOptions.project, no typed@typescript-eslintrules)". So this diff moves no verdict on an untouched file through type information. The fullpnpm lintis CI's.Acceptance notes
planCascadeAtomicityis fixed anyway, for two reasons. It is the scan's documented twin, whose participant test the code requires to equal the scan's. And a scan-only change would have made that comment false. On the showcase it changes no verdict (see Measured readings).owning_business_unit_id,owner_id,created_by,updated_by). Measured on the showcase with the fixture: an admin'sDELETE /api/v1/data/sys_business_unit/:idanswers 400. The body reads "A filter on object 'showcase_ext_customer' names a column the database could not resolve", and the log has[sql-driver] INVALID_FILTER ... ('owning_business_unit_id'). Triage's ruling scopes this card to the tenant field, so the predicate is not widened here. This is reported to the seat for the family's closing card.created_by,updated_byandowner_id, but it is NOT MEASURED:POST /api/v1/auth/admin/remove-useranswered 404 in the verify harness.lifecycle/lifecycle-service.ts. Its per-tenant archive and reap passes filterorganization_idwith no federated branch. Read-only inference, unmeasured. It is reachable only if a lifecycle policy is declared on a federated object.eventOrganizationIdinengine.ts. It reads the row's tenant column value, not the remote's schema. On a federated row the column is absent and the key is omitted. Inference, unmeasured.Generated by Claude Code