Repository navigation
Commit f243a29
fix(objectql): the cascade skips a federated object's injected tenant anchor (#21917)
Fixes #21910
Clause-②: no
## What was wrong
Deleting an organization runs the engine's referential cascade
(`ObjectQL.cascadeDeleteRelations`), which probes every registered
`lookup` / `master_detail` field that references the deleted object. The
registry injects the tenant anchor `organization_id` (a lookup to
`sys_organization`) into every object it registers, federated (ADR-0015
`external`) 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 remote `customers` table on `organization_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 is `organization_id`; the object is
federated by `isFederatedObject`, the predicate `buildDriverOptions` and
the related-record read already ask; and the injected-column provenance
marker (`resolveInjectedColumnProvenance`, the #7865 ruling) answers
`injected-unprovisioned`. An `organization_id` the author declared
answers `author` and 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/objectql` patch, `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 `external` objects and supplies the provenance marker this
predicate reads. No `packages/spec` edit.
## Pins
- Unit,
`packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts`
(5 tests, a two-driver engine, all through `engine.delete`):
- the scan never reads a federated object on its injected
`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);
- an `organization_id` the author declared on a federated object is
still probed, and the probe's failure propagates with its envelope
(`code` `INVALID_FILTER`, `status` 400, same error object);
- another lookup the author declared on a federated object (`org_ref`)
is still probed, and its failure propagates the same way;
- the atomicity plan opens one transaction when the injected anchor was
the only cross-datasource reference. The control: an author-declared
federated lookup still makes the plan cross-datasource, and it logs the
split warning once.
- Door,
`packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts`:
boots the showcase with `orgContext`, provisions the federated fixture
with `onEnable` in its own `mkdtemp` working directory, and asserts its
premises on the same boot. The remote rows are served, the injected
anchor answers `injected-unprovisioned`, and a SYSTEM read filtered on
`organization_id` is refused `INVALID_FILTER`. Then the owner's `POST
/api/v1/auth/organization/delete` answers 200, the row is gone, and the
federated rows are untouched. It neither reads nor writes
`packages/qa/dogfood/.objectstack/data/showcase_external.db`: after the
runs that directory does not exist in this worktree.
- #8895's pins stay green: `engine-cascade-delete-probe-failure.test.ts`
with the rest of the cascade files (8 files, 90 tests), and the whole
`@objectstack/objectql` suite (375 files, 7469 tests).
## Measured readings
- **Before, with the fixture** (base `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-auth `SERVER_ERROR`, and `[AuthPlugin]
... HTTP 500`.
- **Before, with no fixture** (same file, `onEnable` not run, the
federated reads dropped): 200. The probe on `showcase_ext_customer`
fails with `no such table: customers`, and the probe's missing-table
branch passes it.
- **After** (`99eb366577`): the door pin and the unit pin are green.
- **The atomicity plan on the showcase.** Before and after, an
organization delete logs `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 reaches `showcase_ext_customer`
and `showcase_ext_order` through their injected
`owning_business_unit_id` (a lookup to `sys_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`)
- **Leg A: the scan's skip line deleted.** The deletion went through
`scripts/ablation-replace.mjs` (anchor 1 to 0, blob `2073a1d4b84d` to
`03dfd65888d8`). Then `@objectstack/objectql` was rebuilt, and
`ablation-dist-preflight --absent` confirmed 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`.
- **Leg A restore.** The restore proved blob `2073a1d4b84d` equals HEAD
and `git diff HEAD` is empty. After a rebuild, the preflight in present
mode found the marker in 4 built files, with the tree clean.
- **Leg B: the plan's skip line deleted.** Same tool, blob
`2073a1d4b84d` to `351409525155`. Unit pin: 1 failed, 4 passed (the plan
test). The unit pin imports `./engine.js` relatively, so it reads
source, never `dist/`. The restore proved blob equals HEAD, `git diff
HEAD` 0 bytes, and an empty porcelain.
## Gates (at `99eb366577`)
- `node scripts/pm/dispatch-gates.mjs --commands` over the branch
derived the same 71 commands as the dispatch. All 71 exit 0, and `--ran`
reports `71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN`.
- Artifact-roster block, 52 commands: 49 exit 0. Three need a pull
request in their environment and answer exit 2, NOT WIRED / NOT
MEASURED: `check-closing-target-claim.mjs`,
`check-partof-closing-keyword.mjs` and `check-single-claim-paths.mjs`.
Their CI workflows run them.
- Symbol-anchor sweeps: `check:adr-symbol-anchors`,
`check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and
`check:adr-anchors` all exit 0.
- The 11 declared-wide families: all exit 0 (extra).
- `check:objectql-double-limit`: the new stub driver's `find` applies
the caller's `limit` after the filter, by presence. The gate grades it
as applying the bound (452 graded, 255 apply, none new).
- `pnpm --filter @objectstack/objectql typecheck` and `pnpm --filter
@objectstack/dogfood typecheck` are green, and `tsc --listFiles`
includes both new test files.
- ESLint, narrowed to the 4 touched sources:
- population: `eslint.config.mjs` lints
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` outside `NEVER_LINTED`, so the
changeset is not in it;
- count: `--format json` reports 4 files, 0 errors and 0 warnings;
- invariance: the config "never enables type-aware linting (no
`parserOptions.project`, no typed `@typescript-eslint` rules)". So this
diff moves no verdict on an untouched file through type information. The
full `pnpm lint` is CI's.
## Acceptance notes
- **Conflict with the dispatch, stated.** The dispatch said to report
other readers of the tenant field and not fix them here.
`planCascadeAtomicity` is 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).
- **Other readers of a federated object's injected anchors, not covered
here:**
- **The cascade scan on the OTHER injected anchors**
(`owning_business_unit_id`, `owner_id`, `created_by`, `updated_by`).
Measured on the showcase with the fixture: an admin's `DELETE
/api/v1/data/sys_business_unit/:id` answers 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.
- **A user delete.** The same mechanism applies through `created_by`,
`updated_by` and `owner_id`, but it is NOT MEASURED: `POST
/api/v1/auth/admin/remove-user` answered 404 in the verify harness.
- **`lifecycle/lifecycle-service.ts`.** Its per-tenant archive and reap
passes filter `organization_id` with no federated branch. Read-only
inference, unmeasured. It is reachable only if a lifecycle policy is
declared on a federated object.
- **`eventOrganizationId` in `engine.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.
- **Not in this change:** the dogfood fixture leak in the package
directory, which is #21914's.
---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent be97cf3 commit f243a29
5 files changed
Lines changed: 485 additions & 2 deletions
File tree
- .changeset
- packages
- objectql/src
- qa/dogfood/test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
Lines changed: 270 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
304 | 304 | | |
305 | 305 | | |
306 | 306 | | |
307 | | - | |
| 307 | + | |
| 308 | + | |
308 | 309 | | |
309 | 310 | | |
310 | 311 | | |
| |||
15832 | 15833 | | |
15833 | 15834 | | |
15834 | 15835 | | |
15835 | | - | |
| 15836 | + | |
15836 | 15837 | | |
15837 | 15838 | | |
15838 | 15839 | | |
| |||
15856 | 15857 | | |
15857 | 15858 | | |
15858 | 15859 | | |
| 15860 | + | |
| 15861 | + | |
| 15862 | + | |
| 15863 | + | |
| 15864 | + | |
| 15865 | + | |
15859 | 15866 | | |
15860 | 15867 | | |
15861 | 15868 | | |
| |||
16324 | 16331 | | |
16325 | 16332 | | |
16326 | 16333 | | |
| 16334 | + | |
| 16335 | + | |
| 16336 | + | |
| 16337 | + | |
| 16338 | + | |
| 16339 | + | |
| 16340 | + | |
| 16341 | + | |
| 16342 | + | |
| 16343 | + | |
| 16344 | + | |
| 16345 | + | |
| 16346 | + | |
| 16347 | + | |
| 16348 | + | |
| 16349 | + | |
| 16350 | + | |
| 16351 | + | |
| 16352 | + | |
| 16353 | + | |
16327 | 16354 | | |
16328 | 16355 | | |
16329 | 16356 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
3 | 6 | | |
4 | 7 | | |
5 | 8 | | |
| |||
31 | 34 | | |
32 | 35 | | |
33 | 36 | | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
0 commit comments