Repository navigation
Commit 13a22d0
fix(objectql): the cascade skips every injected column a federated object does not provision (#21937)
Fixes #21918
Clause-②: no
## What was wrong
Deleting a record runs the engine's referential cascade
(`ObjectQL.cascadeDeleteRelations`), which probes every registered
`lookup` / `master_detail` field that references the deleted object. The
registry injects its own columns into every object, federated (ADR-0015
`external`) ones included: the tenant anchor `organization_id`, the
ADR-0117 D1 anchor `owning_business_unit_id`, the owner `owner_id`, and
the audit lookups `created_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_id` alone,
through `isFederatedInjectedTenantAnchor`. 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:
- an admin's `DELETE /api/v1/data/sys_business_unit/:id` answered
**400** (`INVALID_FILTER` on
`showcase_ext_customer.owning_business_unit_id`);
- removing a user answered **500** (the same refusal on
`showcase_ext_customer.created_by`, raised inside better-auth).
## What changed
- `packages/objectql/src/federated-object.ts`:
`isFederatedInjectedTenantAnchor` is replaced by one general predicate,
`isFederatedUnprovisionedInjectedColumn(schema, fieldName)`. It is
`isFederatedObject(schema)` and `resolveInjectedColumnProvenance(schema,
fieldName) === 'injected-unprovisioned'`, the registry's own #7865
provenance. It names no column. The `isFederatedObject` conjunct changes
no verdict (the provenance only answers `injected-unprovisioned` on an
`external` object). 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`: `cascadeDeleteRelations` and
`planCascadeAtomicity` ask the general predicate at the same place
#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 own `organization_id` or `owner_id`,
answers `author` and stays in the scan with #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 whose
`organization_id` is a federated unprovisioned injected column.
Measured: the spec accepts a `lifecycle` block beside `external`
(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
`$or` arm. 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/objectql` patch, `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/spec` edit.
## The enumeration pin (the closing act)
`packages/objectql/src/federated-injected-column-readers.test.ts` scans
every non-test source of `@objectstack/objectql` with the TypeScript
parser for every use of a named seam:
- the federated decisions and the provenance they read;
- the relation-carrier arbiters (`referenceCarrierOf`,
`referenceTargetOf`);
- the tenant-column resolver and its constant;
- every spelling of an injected column's name.
The names are not listed. They come from `injectedSystemColumnDefs`
(`@objectstack/spec/data`), the table the registry spreads. Each use is
keyed `FILE#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:
| Disposition | Sites |
|---|---|
| skips (asks the general predicate) | `cascadeDeleteRelations`,
`planCascadeAtomicity`, lifecycle `tenantWindowsFor` (and `reap` /
`archiveObject` through it) |
| exempt (asks `isFederatedObject`) | `buildDriverOptions`, the
related-record read (`resolvePredicateRelated`),
`resolveSystemInsertOrganization` |
| excludes (reads the provenance) | the dangling-reference audit
(`auditableReferenceFields`, `organizationFieldOf`) |
| row-value | `eventOrganizationId` reads the written row; a federated
row has no tenant column, so the key is omitted |
| target-by-id | `assertReferencesResolve`, `expandRelatedRecords`,
`resolveRelatedTitleTarget` |
| caller-predicate | relation-filter lowering, five validation-rule
sites |
| author-declared | `buildSummaryIndex` (a roll-up's FK inference; see
Acceptance notes) |
| policy-subject | lifecycle `created_at` (the age a retention / archive
selects by) |
| writer | the audit hook's `created_by` / `updated_by` stamping |
| not-a-read / definition | name vocabularies, sync routing, injection
constants, refusal text, the predicate and resolver themselves |
## Pins
- `packages/objectql/src/federated-object.test.ts` (5): the general
predicate accepts every injected anchor of a registered federated
object. It agrees with `unprovisionedInjectedColumns` on every field of
three objects. It refuses an author-declared `organization_id`,
`owner_id` and lookup (`author`), and every injected column of a local
object (`injected-provisioned`). It also refuses `id` and inputs that
are not objects.
- `packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts`
(#21910's 5 pins kept, 7 added, through `engine.delete` on a two-driver
engine; the existing stub driver now also logs which columns each read
filters on):
- a business-unit delete never reads the federated object, and a local
object's `owning_business_unit_id` IS probed (control);
- a user delete never reads it, and the local `owner_id` / `created_by`
/ `updated_by` ARE probed;
- author-declared `unit_ref` and `owner_id` on a federated object are
still probed, and only those columns are. Their failure propagates with
its envelope (`INVALID_FILTER`, 400, the same error object);
- both plans run one transaction when injected anchors were the only
cross-datasource references, and an author lookup keeps both plans
`split` with 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 on `organization_id` (the double refuses any read naming it). The
same declaration on a local object, and an `organization_id` the author
declared on a federated object, keep their per-tenant partition.
- The enumeration pin (5).
- Door pins,
`packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts`.
They boot the showcase with `orgContext`, provision the fixture with
`onEnable` in the test's own `mkdtemp` directory, and assert the
premises on the same boot: the remote rows are served, each anchor is
`injected-unprovisioned`, and a SYSTEM read filtered on each one is
refused `INVALID_FILTER`. Then:
- `DELETE /api/v1/data/sys_business_unit/bu_21918` answers 200, the row
is gone, and the federated rows are untouched;
- `POST /api/v1/auth/admin/remove-user` answers 200, the user row is
gone, and the federated rows are untouched.
- #21910's door pin
(`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`, which `plugin-auth` mounts when the
better-auth admin plugin is on.
- `DELETE /data/sys_user/:id` answers 405 by design (ADR-0092), and
`/auth/delete-user` is unconfigured (404).
- `objectstack serve` turns the admin plugin on by default
(`OS_AUTH_ADMIN`, `packages/cli/src/commands/serve.ts`). The verify
harness constructs `AuthPlugin` with no plugin options, so the route
answers 404 there. That is the 404 #21910's dev measured.
- The harness exposes no auth option. The door pin turns the plugin on
through `OS_SCIM_ENABLED`, the one switch the harness reads that does
(ADR-0134), the same knob `admin-credential-lifecycle.dogfood.test.ts`
uses.
- The vendor route authorizes on the legacy `sys_user.role === 'admin'`
scalar, which ADR-0068 D2 retired. So a platform admin is refused there
with 403 `YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS`, a ruled state.
- The pin writes that scalar onto the admin row, as `plugin-auth`'s
`remove-user-atomicity.test.ts` does, because its subject is the cascade
and not the route's authorization.
## Measured readings (showcase with the federated fixture, own temp dir)
| Door | Before (`f243a29290`) | After |
|---|---|---|
| admin `DELETE /data/sys_business_unit/:id` | 400 `INVALID_FILTER` on
`owning_business_unit_id`; log `[sql-driver] INVALID_FILTER ...
showcase_ext_customer ('owning_business_unit_id')` | 200 |
| `POST /auth/admin/remove-user` (admin plugin on, legacy scalar) | 500,
empty body; log `INVALID_FILTER ... ('created_by')`, better-auth
`SERVER_ERROR`; user row survives | 200, row gone |
| same route, platform admin without the scalar | 403
`YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS` (ruled, unchanged) | unchanged |
**Atomicity plan** (`planCascadeAtomicity` on the booted showcase):
organization, business unit and user all read `split` before and
`atomic` after. 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
through `scripts/ablation-replace.mjs`)
- **Leg A, the base predicate restored** (`fieldName ===
'organization_id' &&` put back in front, anchor 1 to 0, blob
`a432c5754eb4` to `cccef63025f7`). After rebuilding
`@objectstack/objectql`, `ablation-dist-preflight` found the marker in 4
built files.
- Unit: 9 failed / 8 passed (3 general-predicate pins, 6 business-unit
and user cascade and plan pins).
- Door: the business-unit delete read `expected 400 to be 200`
(`INVALID_FILTER`), and the user removal `expected 500 to be 200`.
- Restore: blob `a432c5754eb4` equals HEAD and `git diff HEAD` is empty.
After a rebuild, the marker is absent from all 14 built files, and `git
status --porcelain` is empty.
- **Leg B1, a new reader with no disposition** (`referenceTargetOf`
planted in `eventOrganizationId`): the enumeration pin goes red, naming
the unlisted key `engine.ts#eventOrganizationId :: referenceTargetOf()`.
Restored to the HEAD blob.
- **Leg B2, the plan stops asking** (its skip line deleted): 5 failed
(#21910's and #21918's three plan pins, plus the enumeration pin's set
and asks checks). Restored to the HEAD blob.
- **Leg C, the lifecycle partition stops asking**: 4 failed (both
federated lifecycle pins, and the enumeration pin twice). Restored to
the HEAD blob `1b78524d3f29`.
- Legs B and C read source only: the unit pins import relatively, and
the enumeration pin parses `src/`.
## Gates (at `7c2888a239`, after merging `origin/main` `faf8dce482`)
- `node scripts/pm/dispatch-gates.mjs --commands` over the branch
derived the same 71 commands as the dispatch. All 71 exit 0, and `--ran`
reads `71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN`.
`check:dual-build-cjs-loads` first answered exit 3 `PREREQUISITE NOT
MET` (8 packages had no `dist/`). After building them it passes (106
entry points across 66 packages).
- Artifact-roster block: 53 commands; 50 exit 0.
`check-closing-target-claim.mjs`, `check-partof-closing-keyword.mjs` and
`check-single-claim-paths.mjs` need a PR in their environment (exit 2,
NOT WIRED before this PR existed); their CI workflows run them.
- Symbol-anchor sweeps: `check:adr-symbol-anchors`,
`check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors`,
`check:adr-anchors`: all exit 0.
- `check:objectql-double-limit` passes: no new double. The existing stub
driver was extended, and its `find` still applies the caller's `limit`
after the filter.
- `pnpm --filter @objectstack/objectql test` (two shards): 378 files,
7495 tests passed. `pnpm --filter @objectstack/objectql typecheck`:
`tsc` clean, and `check:test-typecheck` reads OK with no new debt (234
ledgered errors, none in the four touched test files, which `tsc
--listFiles` includes). `pnpm --filter @objectstack/dogfood typecheck`:
exit 0, and it includes the door pin.
- ESLint, narrowed to the 8 touched code files:
- population: `eslint.config.mjs` lints
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` outside `NEVER_LINTED`;
- count: `--format json` reports 8 files, 0 errors and 0 warnings;
- invariance: the config never enables type-aware linting (no
`parserOptions.project`, no typed rules), so this diff moves no
untouched file's verdict. The full `pnpm lint` is CI's.
## Acceptance notes
- **`buildSummaryIndex`** (disposition `author-declared`, not changed
here). A roll-up declared with no `relationshipField` infers its foreign
key from the child's first relation to the parent. Injected anchors
point only at `sys_organization`, `sys_business_unit` and `sys_user`, so
it can meet one only for a roll-up declared on one of those, over a
federated child. Inference, unmeasured.
- **The audit hook is a writer** (`plugin.ts#registerAuditHooks` stamps
`created_by` / `updated_by` on 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.
- **Lifecycle `created_at` stays the policy's subject.** A federated
object that declares `retention` (or `archive` without `ttl`) reaps by
`created_at`, which is the registry's injection there. A remote without
it refuses the filter, and the sweep reports the object in `errors`
every sweep. The spec accepts the declaration and lint does not warn.
- **A tenant-scoped retention override naming a federated object**
selects no rows, as it would on a provisioned object whose rows all
carry no organization. The object's sweep now runs its global window
instead of failing.
- **The verify harness has no auth plugin options.** Its `AuthPlugin`
differs from `serve.ts`'s default (`admin` on), so vendor admin routes
answer 404 under the harness unless a test sets `OS_SCIM_ENABLED`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 131b937 commit 13a22d0
9 files changed
Lines changed: 1345 additions & 65 deletions
File tree
- .changeset
- packages
- objectql/src
- lifecycle
- 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: 191 additions & 7 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
29 | 29 | | |
30 | 30 | | |
31 | 31 | | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
32 | 39 | | |
33 | | - | |
34 | | - | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
35 | 43 | | |
36 | 44 | | |
37 | 45 | | |
| |||
44 | 52 | | |
45 | 53 | | |
46 | 54 | | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
47 | 68 | | |
48 | | - | |
49 | | - | |
50 | | - | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
51 | 73 | | |
52 | | - | |
| 74 | + | |
53 | 75 | | |
54 | 76 | | |
55 | 77 | | |
| |||
69 | 91 | | |
70 | 92 | | |
71 | 93 | | |
| 94 | + | |
72 | 95 | | |
73 | 96 | | |
74 | 97 | | |
| |||
176 | 199 | | |
177 | 200 | | |
178 | 201 | | |
179 | | - | |
| 202 | + | |
180 | 203 | | |
181 | 204 | | |
182 | 205 | | |
| |||
268 | 291 | | |
269 | 292 | | |
270 | 293 | | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
304 | 304 | | |
305 | 305 | | |
306 | 306 | | |
307 | | - | |
308 | | - | |
| 307 | + | |
| 308 | + | |
309 | 309 | | |
310 | 310 | | |
311 | 311 | | |
| |||
15857 | 15857 | | |
15858 | 15858 | | |
15859 | 15859 | | |
15860 | | - | |
15861 | | - | |
15862 | | - | |
15863 | | - | |
| 15860 | + | |
| 15861 | + | |
| 15862 | + | |
| 15863 | + | |
| 15864 | + | |
15864 | 15865 | | |
15865 | | - | |
| 15866 | + | |
15866 | 15867 | | |
15867 | 15868 | | |
15868 | 15869 | | |
| |||
16331 | 16332 | | |
16332 | 16333 | | |
16333 | 16334 | | |
16334 | | - | |
16335 | | - | |
16336 | | - | |
16337 | | - | |
16338 | | - | |
16339 | | - | |
16340 | | - | |
| 16335 | + | |
| 16336 | + | |
| 16337 | + | |
| 16338 | + | |
| 16339 | + | |
| 16340 | + | |
| 16341 | + | |
| 16342 | + | |
| 16343 | + | |
| 16344 | + | |
| 16345 | + | |
16341 | 16346 | | |
16342 | | - | |
16343 | | - | |
16344 | | - | |
| 16347 | + | |
| 16348 | + | |
| 16349 | + | |
| 16350 | + | |
16345 | 16351 | | |
16346 | 16352 | | |
16347 | 16353 | | |
16348 | | - | |
16349 | | - | |
16350 | | - | |
16351 | | - | |
16352 | | - | |
| 16354 | + | |
| 16355 | + | |
| 16356 | + | |
| 16357 | + | |
| 16358 | + | |
16353 | 16359 | | |
16354 | 16360 | | |
16355 | 16361 | | |
| |||
0 commit comments