Skip to content

fix(objectql): the cascade skips a federated object's injected tenant anchor - #21917

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21910-cascade-federated-tenant-field
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21910-cascade-federated-tenant-field

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 [Decision] applySystemFields injects platform anchors into external objects the platform provisions no storage for — three consumers have now independently re-derived "that column is not really there" #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.
  • ObjectQL.cascadeDeleteRelations fails OPEN: a failed dependents probe skips the restrict guard entirely, so a delete that should be refused succeeds silently #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 dogfood: five files boot the showcase in the package directory and leave its federated fixture database behind, so a later showcase boot's federated state depends on shard order #21914's.

Generated by Claude Code

claude added 3 commits October 5, 2026 22:41
… 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>
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 3 documentable anchor(s).

⛔ 1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via cascadeDeleteRelations (symbol, a method of class ObjectQL))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e6dc7a240617eaeef9a64e788bf6e5561c107f1b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1f8c7a8dddb33e65a12cc750e14fc29969925454 — the merge of head 99eb3665777cdd9c84cd9e6ea485e833ef32a27d into base e6dc7a240617eaeef9a64e788bf6e5561c107f1b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e6dc7a240617eaeef9a64e788bf6e5561c107f1b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT (seat review) — PR #21917 at head 99eb366577

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-05T23:31Z. The os-dev report is on #21910 (6005431188). Judged against GitHub and the branch, not against the report.

  • Shape: draft, base main.
    • The first lines are Fixes #21910 and Clause-②: no.
    • Assignee: os-project-manager.
  • Scope: 5 files, +485/-2:
    • objectql's engine.ts (+31/-2) and federated-object.ts (+42);
    • the unit pin;
    • the dogfood door pin;
    • the changeset.
  • The diff, read: triage's direction (6003909101), as ruled.
  • One deviation, accepted. planCascadeAtomicity asks the same predicate. Its own comment says its participant test is the scan's "so the two cannot disagree about who participates", and a scan-only change would have made that false. On the showcase it moves no verdict: the plan still reaches the federated objects through owning_business_unit_id. The claim's surface named the scan; this is the same function family in engine.ts, within the claimed file.
  • Public surface: federated-object.ts is not re-exported from @objectstack/objectql's entry (neither is isFederatedObject), so the new predicate is internal. Clause-②: no is right. No packages/spec path, so no contract review is owed.
  • Door readings (H1):
    • With the federated fixture provisioned, an owner's POST /api/v1/auth/organization/delete answered 500 at base e6dc7a2406. The log reads [reference-cleanup] … showcase_ext_customer … organization_id → INVALID_FILTER → SERVER_ERROR.
    • It answers 200 at the head.
    • With no fixture, it answers 200 at base too.
  • Pins:
  • Reverse verification, from committed 99eb366577, each restore proved by blob equality and an empty git diff HEAD:
    • Leg A, the scan's skip line removed and rebuilt: 1 unit test red, and the door pin reads expected 500 to be 200.
    • Leg B, the plan's skip line removed: 1 unit test red.
  • Changeset, checked sentence by sentence: @objectstack/objectql: patch, with Clause-②: no. "What was wrong", "what changed" and "what did not change" each match the diff.
  • Evidence:
  • Gates:
    • dispatch-gates --ran: 71 of 71 exit 0, including check:objectql-double-limit after the stub driver was fixed.
    • The 52 roster commands, the 11 wide families, the four symbol-anchor sweeps and the 3 PR-context guards all pass.
  • CI: read at landing.

Filed from this card: the dev measured the same mechanism on another injected anchor. With the fixture provisioned, an admin's DELETE /api/v1/data/sys_business_unit/:id answers 400 INVALID_FILTER on showcase_ext_customer.owning_business_unit_id. That is the "third face" triage foresaw (6003909101). The seat files it as a finding for the family's closing card.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 23:59
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 23:59
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit f243a29 Oct 6, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21910-cascade-federated-tenant-field branch October 6, 2026 00:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants