Skip to content

fix(objectql): the cascade skips every injected column a federated object does not provision - #21937

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21918-federated-injected-anchors
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21918-federated-injected-anchors

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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

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 (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, 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.
  • 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 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 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.
  • 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)

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

claude added 5 commits October 6, 2026 01:07
… 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>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 6, 2026
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via LifecycleService (symbol, a top-level class))
  • content/docs/data-modeling/objects.mdx (via LifecycleService (symbol, a top-level class), expireAfter (literal, a string literal in tenantWindowsFor), maxAge (literal, a string literal in tenantWindowsFor))
  • content/docs/deployment/production-readiness.mdx (via maxAge (literal, a string literal in tenantWindowsFor))
  • content/docs/kernel/services.mdx (via LifecycleService (symbol, a top-level class))
  • content/docs/permissions/attachments-access.mdx (via LifecycleService (symbol, a top-level class))
  • content/docs/protocol/knowledge.mdx (via LifecycleService (symbol, a top-level class))

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

  • content/docs/releases/v14.mdx (via LifecycleService (symbol, a top-level class))
  • content/docs/releases/v15.mdx (via LifecycleService (symbol, a top-level class))
  • content/docs/releases/v17/17-1.mdx (via cascadeDeleteRelations (symbol, a method of class ObjectQL))
  • content/docs/releases/v17/17-5.mdx (via expireAfter (literal, a string literal in tenantWindowsFor))

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
  • 2 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 37 pages), organization_id (literal, 32 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 9dce635337c2cc42a4149aa49289ad77d172363d → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2ab3b0073b37a2d709d5d58ef4183628665f8ef3 — the merge of head 85098a49af592a66fec2048e93c0fc97186905dc into base 9dce635337c2cc42a4149aa49289ad77d172363d, 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 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

⚠️ 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 9dce635337c2cc42a4149aa49289ad77d172363d → 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 #21937 at head 7c2888a239

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-06T02:14Z. The os-dev report is on #21918 (6007928982). Judged against GitHub and the branch, not against the report.

  • Shape: draft, base main.
    • The first lines are Fixes #21918 and Clause-②: no.
    • Assignee: os-project-manager.
  • Scope: 9 files, +1323/-65: objectql's federated-object.ts, engine.ts and lifecycle/lifecycle-service.ts; four test files; one dogfood door file; the changeset. All of it is within the claim (6006788841). The lifecycle half was conditional and is in scope by measurement: ObjectSchema.safeParse accepts retention / ttl / archive beside external.
  • The diff, read: triage's direction (6005884657), as ruled.
  • Door readings (H1):
  • Plan verdicts (H3): organization, business unit and user each move from split to atomic. The scan's probed set equals the plan's first-level participants (53 = 53, 27 = 27, 66 = 66, 0 federated each).
  • Enumeration pin (H4): a source scan over objectql/src's named seams (the federated decisions, the provenance, the relation-carrier arbiters, the tenant field, and every injected column name taken from injectedSystemColumnDefs). Each use is keyed FILE#FUNCTION :: SEAM against a closed disposition table: an unlisted key fails, and a stale row fails. A scan rather than a registry is the right shape for a family whose failing readers did not know the question existed.
  • Reverse verification, from committed 1a131e4b4b, each restore proved by blob equality and an empty git diff HEAD:
    • A, the tenant-only base predicate restored and rebuilt: 9 unit tests red, and both door pins red.
    • B1, an unlisted referenceTargetOf planted in eventOrganizationId: the enumeration pin names it and goes red.
    • B2, the plan's skip removed: 5 red.
    • C, the lifecycle helper's skip removed: 4 red.
  • Door harness, accepted: the user-delete pin turns the admin plugin on through OS_SCIM_ENABLED and writes the legacy role: 'admin' scalar on the admin row, as admin-credential-lifecycle.dogfood.test.ts and plugin-auth's remove-user-atomicity.test.ts do. packages/verify is not edited. The harness gap is in the PR body.
  • Changeset, checked sentence by sentence: @objectstack/objectql: patch, with Clause-②: no. "What was wrong", "what changed" (the lifecycle global pass included) and "what did not change" each match the diff.
  • Evidence:
    • objectql passes 7495 tests in 378 files (two shards).
    • objectql and dogfood typecheck exit 0, with no new test-typecheck debt.
    • The door files pass 5 of 5.
    • Narrowed eslint over the 8 touched code files: 0 errors, 0 warnings.
  • Gates: dispatch-gates --ran: 71 of 71 exit 0. The artifact roster (53), the four symbol-anchor sweeps and the 3 PR-context guards against this PR all pass.
  • Recorded, not filed (the PR's Acceptance notes):
    • a lifecycle created_at subject on a federated object, which has no measured producer;
    • the verify harness's admin-plugin default;
    • buildSummaryIndex's roll-up FK inference (inference only);
    • the audit stamp on a writable federated datasource (inference only).
  • CI: read at landing.

Generated by Claude Code

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT (seat review, head moved) — PR #21937 at head 85098a49af

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-06T03:10Z. This extends the ACCEPT at 7c2888a239 (6007951565) to the one commit since. The dev's follow-up report is on #21918.

  • Why CI was red at 7c2888a239: Test Core (4/6) failed in objectql's pnpm run test. The job log is behind a host this container cannot reach, so CI's own failing line is NOT MEASURED. The dev measured the cause instead:
    • the enumeration pin's three scanning tests each re-parsed all 68 sources and climbed from every AST node to the root, at 1.2–1.7 s per scan unloaded;
    • under CPU contention of a Test Core shard's shape, two of them hit vitest's 5000 ms default (Test timed out in 5000ms, exit 1).
    • CI's "Test completeness guard" passed on that job, which rules out a crash or a stall.
  • The fix, 85098a49af (a new commit; no amend, no force-push): one file, federated-injected-column-readers.test.ts, +52/-30.
    • The walk passes the enclosing site down instead of climbing per node.
    • The scan is memoized once per file run.
    • The tests that may run it first declare a 30 s budget.
    • ⛔ The READERS table and every assertion are unchanged, so the pin's exact-set checks still prove the seam set.
  • Reverse verification at the head: B1 (an unlisted referenceTargetOf in eventOrganizationId) is caught by name, and B2 (the plan's skip removed) goes red. Both were restored by blob equality.
  • Readings:
    • unloaded, the one scan takes 661 ms; at 14 busy loops it takes 2428 ms and 5 of 5 pass;
    • objectql's run test passes 7490 tests in 377 files, and typecheck holds with no new debt;
    • CI on 85098a49af is 32 success and 3 skipped, every skip in the roster;
    • NOT governed, and git merge-tree against main is clean.
  • Docs drift (advisory): the seat read the flagged objects.mdx / production-readiness.mdx lines on main. None states a per-tenant lifecycle rule this PR changes, so no doc edit is owed.

Generated by Claude Code

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/xl tests tooling

Projects

None yet

2 participants