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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ctory (objectstack-ai#21919)

Fixes objectstack-ai#21914
Clause-②: no

## What changes

Every dogfood test file now runs in its own temporary working directory.
The suite fails when any file leaves `.objectstack/data` in
`packages/qa/dogfood`.

- **`test/per-file-cwd.setup.ts`** (new) is a `setupFiles` entry, wired
explicitly in BOTH projects of `vitest.config.ts`, because inline
projects inherit nothing from the root block. `shared-showcase` keeps
`isolate: false`; the module still runs once per file there.
- At module top level, before the test file's own imports, it creates a
directory under the run's temporary root and `chdir`s into it.
- In `afterAll` it restores the previous cwd. That `afterAll` is also
**the guard**: it THROWS when `packages/qa/dogfood/.objectstack/data`
exists. The message names the directory, its entries and the remedy. It
also says the named file may be a concurrent one on another worker
rather than the writer, and whether the directory was already present
when the file started.
- **`test/per-file-cwd.global-setup.ts`** (new) is a root-level
`globalSetup`. It runs once per run, covering both projects and each
`OS_TEST_SHARD` slice (measured).
- At the START it clears a stale `packages/qa/dogfood/.objectstack`, so
a developer's earlier run never reds the suite.
- It creates one temporary root for the run and hands it to the workers
with `provide` / `inject`.
- At the END it removes that root, which is **where the per-file
directories are removed**. The removal is run-level, not per file,
because the memoized `shared-showcase` boot keeps its SQLite handles
open in the directory of the file that booted it.
- The teardown judges nothing (see Evidence: a throwing teardown is a
false green).
- **`vitest.config.ts`** wires the two modules. A header section
explains why there are two halves and why the guard is not in the
teardown.
- **`test/enterprise-organizations.ts`**: the module-level
`probeOrganizations()` now passes this package's root as `hostRoot`,
resolved from the module's location (`new URL('..', import.meta.url)`),
not the cwd. This was measured to be needed; see Evidence.

No per-file edits. The five files the card names, and the other 87
measured writers, are covered by the module with no change of their own.
Test isolation only: `@objectstack/dogfood` is `private: true`, so no
published package moves and there is no changeset (`skip-changeset`).

## The invariant for every dogfood author

- **Each test file runs in its own temporary cwd.** Anything it writes
relative to the cwd is its own, no other file sees it, and it is removed
at the end of the run. A file needs no `mkdtemp` / `chdir` of its own.
- **A package-relative read must resolve from the module's location**
(`new URL('..', import.meta.url)`, `import.meta.dirname`), never from
`process.cwd()`. The cwd is a temporary directory.
- **A file that writes into `packages/qa/dogfood/.objectstack/data`
fails the run.** That happens through an absolute path built from the
package root, or through a `process.chdir()` back to the package
directory before a boot. The fix is to write relative to the file's own
cwd.
- Files that already `chdir` into a temp dir of their own still work,
because they restore to the per-file directory. Their own `chdir` is now
redundant and harmless.

## Why (measured)

A per-file probe over the whole suite measured 92 test files leaving
`.objectstack/data/showcase_external.db` in the package directory, not
the five the card names:

- 7 leave the populated federated fixture (24576 B, 2 tables): the
card's five, plus `showcase-demo-personas-loginable` and
`showcase-demo-personas-membership`, which pass `onEnable` in the
bundle.
- 85 leave an empty SQLite file (4096 B, 0 tables). The showcase's
declared external datasource has a cwd-relative filename, and its
auto-connect creates the file on every showcase boot, `onEnable` or not.

A later boot on the same runner found or missed the federated tables
depending on which files ran before it, and that ordering is how PR
objectstack-ai#21905 went red only on dogfood shard 3/3. The seat chose this route
(one module) and this guard (comment `6004950414` on objectstack-ai#21914), on the
dev's measurement (comment `6004909676`).

## Evidence

All runs are at head `967ce88a`, under the shared verify lock, from a
clean package directory.

- **Whole suite**: `pnpm --filter @objectstack/dogfood test` gave `Test
Files 205 passed | 1 skipped (206)` and `Tests 1591 passed | 9 skipped
(1600)`. Afterwards `packages/qa/dogfood/.objectstack` does not exist,
and no `os-dogfood-run-*` root is left in the temp dir.
- **CI's three-shard split**: CI's dogfood leg exports
`OS_TEST_SHARD=k/3` and `vitest.config.ts` turns it into vitest's
`shard`. Here each shard ran as `OS_TEST_SHARD=k/3 pnpm --filter
@objectstack/dogfood test`: the same vitest selection, without turbo, so
no cached replay. Each exited 0 and left no `.objectstack`:

  | shard | Test Files | Tests |
  |---|---|---|
  | 1/3 | 69 passed (69) | 507 passed (507) |
  | 2/3 | 69 passed (69) | 461 passed, 1 skipped (462) |
  | 3/3 | 67 passed, 1 skipped (68) | 623 passed, 8 skipped (631) |

  The three add up to the whole run: 206 files, 1600 tests.
- **Ablation (H4)** through `scripts/ablation-replace.mjs`, wrap mode.
The central `process.chdir(...)` was replaced by the bare
`mkdtempSync(...)`: anchor count 1 to 0, blob `0991eb9c` to `ee5a65be`.
- With the chdir dropped, `showcase-external-autoconnect` and
`showcase-search` ran: `Test Files 2 failed (2)`, `Tests 8 passed (8)`,
exit 1. Each failed in the guard:
`.../packages/qa/dogfood/.objectstack/data exists after this test file
ran. Entries: showcase_external.db` (plus `-shm` / `-wal` for the
shared-showcase file).
- Restore was proven by the tool: blob after restore equals HEAD
(`0991eb9c`), and `git diff HEAD` is empty.
  - The same two files then gave `2 passed`, exit 0, and left nothing.
- No build step is involved: vitest loads the mutated module from
source.
- **Stale directory**: `.objectstack/data/x.db` was planted, then 9
files were run. Result: `Test Files 9 passed (9)`, exit 0, nothing left
(the globalSetup cleared it).
- **Census**: those 9 files are the 7 populated-fixture writers plus
`showcase-search` and `showcase-permission-zoo`, both `shared-showcase`
files.
- **`hostRoot` line, measured both ways**, running `rls-multitenant`,
`org-create-default-team` and `enterprise-organizations.test`:
- Without the line (commit `4d07dc29`), the skip text read `not
resolvable from /tmp/os-dogfood-run-.../file-...` and told the reader to
declare the package in that temp directory's `package.json`.
  - With it (`967ce88a`), the text names `packages/qa/dogfood/`.
- The verdict is the same both ways (skipped), because no framework
package declares `@objectstack/organizations`.
- **Guard placement**: a throwing `globalSetup` teardown was measured on
vitest 4.1.11 to print `error during close` and still exit 0, a false
green. So the guard is the per-file `afterAll`. (A teardown that sets
`process.exitCode = 1` does exit 1, but the summary still reads
all-passed.)
- **Typecheck and lint**: `pnpm --filter @objectstack/dogfood typecheck`
is green, and `tsc --listFiles` includes both new modules and
`enterprise-organizations.ts`. `pnpm lint` exits 0.
- **Gates**: 130 commands at `967ce88a`, the dispatch list plus `pnpm
check:dispatcher-error-vocabulary` from `dispatch-gates --commands`.
`dispatch-gates --ran`: `48 derived famil(ies) accounted for — 48 run, 0
NOT-MEASURED`.
- `check:dual-build-cjs-loads` and `check:published-readme-exports`
first exited 3 (dist prerequisite: 7 packages unbuilt). After building
those 7, both exit 0.
- The three PR-context scripts (`check-closing-target-claim`,
`check-partof-closing-keyword`, `check-single-claim-paths`) are re-run
with this PR's context; the results are in the report on the card.

## Open PRs that add dogfood files

| PR | new file | boots the showcase | own `chdir` | under this PR |
|---|---|---|---|---|
| objectstack-ai#21864 | `showcase-public-form-withdrawal-layers.dogfood.test.ts` |
yes | no | Covered with no author action. Without this PR it would leave
`.objectstack/data` in the package directory. |
| objectstack-ai#21917 | `organization-delete-federated-fixture.dogfood.test.ts` |
yes, with `onEnable` | yes | Unaffected; its own `chdir` is redundant. |
| objectstack-ai#21906 | `external-import-code-datasource-namespace.dogfood.test.ts`
(also edits three `external-*` files) | yes, with `onEnable` | yes |
Unaffected. None of its files is edited here. |
| objectstack-ai#21877 | `datasource-contractless-credentials.dogfood.test.ts` | yes |
yes | Unaffected. |
| objectstack-ai#21897 | `flow-node-config-values-at-registration.dogfood.test.ts` |
no (fixture stack) | no | Runs in its own temp cwd; it reads nothing
relative to the cwd. |

None of these files reads a package-relative path through
`process.cwd()`. Only their own `prevCwd` captures do.

## Acceptance notes

- **Observation, not filed.** The showcase's external datasource is
declared read-only (`schemaMode: 'external'`, `allowWrites: false`). Its
auto-connect CREATES a missing `.objectstack/data/showcase_external.db`,
plus `-wal` / `-shm` (measured on 85 harness boots).
- The declaration's own comment in `showcase-external.datasource.ts`
says that if the fixture file cannot be opened, "the boot stops with
that as the reason rather than serving a showcase whose federation pages
are quietly dead".
- It was measured only through the verify harness's `bootStack`, never
at a public door (`os start` / `os dev`), so it stays here.
- **Latent, unreachable today.** `bootStack(..., { multiTenant: true })`
also defaults its `hostRoot` to the cwd:
`rls-multitenant.dogfood.test.ts:79`, and
`attachments-permission-matrix.dogfood.test.ts:766` through
`bootFixture`. Both are gated on `organizationsAvailable`, which is
false in this repository because no framework package may declare
`@objectstack/organizations` (ADR-0132). A run that declares it in this
package would need those boots to pass the package root too. Carrier:
whoever declares it.
- The own `chdir` in `external-validate-sees-runtime-save`,
`external-import-destructive-remedy`, PR objectstack-ai#21906's file and PR objectstack-ai#21917's
file is now redundant. It is left untouched and can be removed once
objectstack-ai#21906 lands. Carrier: the `domain:cli` seat.
- Attribution limit: under parallel workers, the guard can name a file
that ran at the same time as the writer. The message says so, and says
whether the directory was already present when the named file started.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ecision in words instead of a tracker number (stage 22) (objectstack-ai#21931)

Part of objectstack-ai#20749
Clause-②: no

Stage 22 of this card: the next area of class (e), the test strings
shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513.
This stage takes the third name-ordered `ui/` group: the 29 id-bearing
test files directly under `packages/spec/src/ui/` from
`dataset-filter-nested-relation-list.test.ts` to
`view-inline-object-binding.test.ts`. Those files carried 96 messages
and 102 tracker ids, citing 52 records. 100 of those ids now either
state what their record decided, in words (form D), or are dropped where
the title already says it. Two stay: they are needles, ids that an
assertion reads in another file's text (below). Text only: no assertion,
identifier, test count or code comment changes, and no file is renamed.

## Census at the base (`be97cf3c93`)

Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`),
`census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs`
(md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5
`dda605c54745b4a60cc14c9a686e4eff`), byte-identical to the copies stages
10 to 21 used. A literal counts as a test title when its folded message
is argument 0 of a `describe` / `it` / `test` call, `.each` / `.skip` /
`.only` chains included. Everything else is an "other" string.

The worktree was cut from `origin/main` at `be97cf3c93`, four commits
past the claim's `3dbd084209`. At the claim's base both instruments read
**665 messages / 702 ids**, the seat's reading and stage 21's head
reading. At `be97cf3c93` they read **665 / 704 in 154 files**: the two
extra ids are in `system/metadata-form-zod-reconciliation.test.ts` (22
to 24 ids), a ledger `why` string that objectstack-ai#21901 rewrote. No `ui/` file
moved.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `ui/` (this PR: 29 of the 48 files) | 48 | 201 / 213 | 186 / 198 | 15
/ 15 |
| `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 |
| `system/` | 34 | 154 / 167 | 128 / 138 | 26 / 29 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| `ai/` | 1 | 2 / 2 | 0 | 2 / 2 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **154** | **665 / 704** | **612 / 648** | **53 / 56** |

The group reads **96 messages / 102 ids in 29 files**, the seat's
figures file for file:

| file (under `ui/`) | messages / ids | titles | other |
|:--|--:|--:|--:|
| `dataset-filter-nested-relation-list.test.ts` | 5 / 5 | 5 / 5 | 0 |
| `door-reachability.testkit.test.ts` | 4 / 4 | 3 / 3 | 1 / 1 |
| `expression-scope-app-root.pin.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `form-layout-inline-grid-retired.test.ts` | 4 / 4 | 3 / 3 | 1 / 1 |
| `form-option-enum-derive.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `i18n-label-resolver.test.ts` | 5 / 6 | 5 / 6 | 0 |
| `i18n.test.ts` | 6 / 6 | 5 / 5 | 1 / 1 |
| `inline-action-type.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `inline-action.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `interaction-config-retirement.test.ts` | 4 / 4 | 2 / 2 | 2 / 2 |
| `joined-report-block-type.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `master-detail-detail-sort-field-retirement.test.ts` | 1 / 1 | 1 / 1 |
0 |
| `notification-embed-retirement.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `notification.test.ts` | 4 / 4 | 3 / 3 | 1 / 1 |
| `page.test.ts` | 2 / 3 | 2 / 3 | 0 |
| `react-blocks.test.ts` | 3 / 4 | 3 / 4 | 0 |
| `report-joined-block-dataset.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `report.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `responsive.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `section-group-reference.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `strictness-batch14.test.ts` | 4 / 5 | 3 / 4 | 1 / 1 |
| `view-authoring-wire-split.test.ts` | 11 / 11 | 11 / 11 | 0 |
| `view-console-round-trip-keys.test.ts` | 5 / 5 | 5 / 5 | 0 |
| `view-field-order-composition.pin.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `view-filter-rule-value-shape.test.ts` | 8 / 9 | 8 / 9 | 0 |
| `view-filter-rule-wire-id.test.ts` | 4 / 5 | 4 / 5 | 0 |
| `view-form-features-root.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `view-gantt-tree-config-closed-15469.test.ts` | 4 / 4 | 4 / 4 | 0 |
| `view-inline-object-binding.test.ts` | 4 / 4 | 4 / 4 | 0 |
| **29 files** | **96 / 102** | **89 / 95** | **7 / 7** |

Seventeen more test files sit in the same name range and carry no id.
The seven "other" strings are the two needles below and five strings
rewritten and declared to the text-only tool: the expect messages at
`door-reachability.testkit.test.ts:216`, `i18n.test.ts:166-167` (the id
is on `:167`), `interaction-config-retirement.test.ts:148` and `:189`,
and the door name at `form-layout-inline-grid-retired.test.ts:61`, which
`describe(door.name, …)` prints as a title.

- **Controls.** Lit: `ui/view.test.ts`, outside the group, reads 43 ids
at the base and at the head. Dark: `view-authoring-wire-split.test.ts`
reads 0 at the head while 11 of its comment lines still carry a number.
Planted in scratch copies of head files: an id put into an
`inline-action-type.test.ts` title reads 1 / 1, and an id put into a
`report.test.ts` comment reads 0.
- **A wider pattern** (any `#` plus digits) reads the same as the gate
pattern in all 29 files at the base.
- **At the head:** 571 messages / 604 ids in 127 files. The 29 files
read 2 / 2 (the two needles), `ui/` reads 107 / 113, and no other file
moved.

## How the area was chosen

`ui/` has no subdirectory test file with an id, so it is taken in
name-ordered file groups near the ~100-id bound. Stage 21's re-cut named
this group at 102 ids, and this census reads 102, so no re-cut was
needed.

**Named for the next stages** (cut from the head census, 571 / 604):
- `ui/` 113 ids. 106 sit in the last group, the 16 files from
`view-item-config-type.test.ts` to `widget.test.ts` (100 messages / 106
ids; `view.test.ts` alone 43, `view-strictness-batch18.test.ts` 11,
`view-overlay-viewkind-arm.test.ts` 10). The other 7 are kept items:
stage 20's `component-props-unknown-members.pin.test.ts:322`, stage 21's
four colour literals, and this stage's two needles.
- `api/` 201, two stages. `system/` 167, two. The files directly in
`src/`, 120, one.
- The needles: the three docblock needles (`ai/build-progress.test.ts`
x2, `contracts/approval-service.test.ts`), the kept `:322`, and this
stage's two. One stage, with an at-tier review.

## The two needles, kept

- **`notification.test.ts:123`**, `source.indexOf('// [objectstack-ai#4610]')`. The
`./ui notification tombstone` pins read `ui/notification.zod.ts` and
slice it at this anchor, the comment that opens the tombstone at
`ui/notification.zod.ts:94`; `:134` then asserts the slice starts with
it. The id is the anchor text of a source comment, so it can only leave
together with that comment.
- **`strictness-batch14.test.ts:395`**,
`expect(source).toContain('objectstack-ai#5015')`. It reads `ui/notification.zod.ts`
and `ui/sharing.zod.ts` and asserts that both record the retirement by
citing the record. Those citations sit in source comments at
`ui/notification.zod.ts:48` and `:103`, and `ui/sharing.zod.ts:22` and
`:106`.

The five other "other" strings are failure messages of assertions whose
expected values carry no id, plus one door name. None is a needle.

## What each id became

- **24 literals (29 ids)** now state a decision in words.
- **22 literals (22 ids)** get their subject back in words, where the
number stood for a thing.
- **48 literals (49 ids)** drop a number the title already explains.

Every cited record was read with its comments through REST: 51 answer
200 and 1 answers 404. Three citations are cross-repo, `objectui#3907`,
`ui#6206-B` and `objectui#6262`, and were read from objectui. Two
records closed with no comment, objectstack-ai#3916 and objectstack-ai#4413; their decisions were
read from what landed: `f752ee3` ("give reports a sort declaration")
with `a831df1` ("`report.order` is live"), and `ebb209c` ("withdraw the
`record:*` blocks from the react tier — no renderer read the props it
published"). objectstack-ai#11284 answers 404; it was read from its landing commit
`5383fa6` (PR objectstack-ai#11695) and that commit's CHANGELOG entry.

Where a record's first decision was later corrected, the title follows
the corrected one:
- **objectstack-ai#15184:** its first ruling retired `fieldOrder`; ruling B superseded
it on a measured false premise (keep the key, declare the `columns` x
`hiddenFields` x `fieldOrder` composition). The title reads "the
list-view field composition is declared, not implied", which is ruling
B.
- **objectstack-ai#6227 and objectstack-ai#19514:** objectstack-ai#6227 recorded `equals` + array as accepted;
objectstack-ai#19514 reversed that on measurement. The two titles say so, in that
order.
- **objectstack-ai#20456:** not every census key is declared (a key the census mapped
to an existing spelling stays undeclared, which `:147` pins), so the
census describe names what the census records, not "every key is
declared".

**Stated in words:**

| record | literal (under `ui/`) | now reads | the decision |
|:--|:--|:--|:--|
| objectstack-ai#20080 | `dataset-filter-nested-relation-list.test.ts:116` | "§1 —
both analytics carriers refuse a list inside a nested relation, at save"
| Remedy A (triage `5825670610`): the two analytics carriers refuse the
list when the filter is saved, not when it is charted; the shared
`FilterConditionSchema` stays as ruled. |
| objectstack-ai#5056 | `door-reachability.testkit.test.ts:156` | "regression — the
any-one-shared-property bridge stays dead; a share of the shape decides"
| The derived-clone bridge stops firing on any one shared property and
requires a whole-shape overlap of at least 0.5. |
| objectstack-ai#5828 | `door-reachability.testkit.test.ts:216` (expect message) |
"the residual false-reachable case — no threshold excludes it" | Closed
not planned: no threshold separates a small all-shared-leaf shape from a
real derivation that also scores 1.0, so the `KNOWN BOUND` pin is the
record. |
| objectstack-ai#17203 | `expression-scope-app-root.pin.test.ts:84` | "no UI prose
face advertises `app` as an expression-scope root — the renderer no
longer mounts it" | Option B of decision batch objectstack-ai#67: objectui stopped
binding `app`, and the engine's `SCOPE_ROOTS` is the contract. |
| objectstack-ai#6761, objectstack-ai#6765 | `i18n-label-resolver.test.ts:281` | "resolveI18nLabel —
rule parity with objectui pickLocalized (ruled: one shared resolver, on
the server)" | Maintainer ruling B on objectstack-ai#6761: an inline locale map is
resolved to a string server-side, by one shared resolver in
`packages/spec`; objectstack-ai#6765 is that resolver, held to `pickLocalized`'s rule.
|
| `objectui#3907` | `i18n-label-resolver.test.ts:340` | "… the rule
departures converged once objectui read only own, string-valued entries;
one departure survives" | `pickLocalized` gained the own-property check
and the string filter on every limb. |
| objectstack-ai#10492 | `i18n.test.ts:99` | "rejects a lone `key`, which used to
parse as a locale map" | A lone `{ key }` parsed as a map for a language
called `key`; it is refused by name, under the retired key-reference
ruling. |
| objectstack-ai#6828 | `inline-action.test.ts:225` | "object-form `params` prescribes
per action type — its url meaning is retired, not re-keyed" | Maintainer
ruling 2026-08-10: retire the third meaning; no new key, and the refusal
guidance branches by action type. |
| objectstack-ai#4988 | `interaction-config-retirement.test.ts:60`, `:189` (expect
message) | "ui/ interaction config family retirement — renderer
behaviour, not authored metadata"; "… being undone — these are renderer
behaviour, not authored metadata" | Maintainer ruling A (2026-08-04):
the five files are retired; they are renderer built-in behaviour, not
per-page metadata. |
| objectstack-ai#21768 | `master-detail-detail-sort-field-retirement.test.ts:489` |
"the narrowing exempts only an inline grid field's own `sortField`, a
key the grid widget declares" | The `object-form` runtime form field
declares the grid widget's eight camelCase keys, `sortField` among them.
|
| objectstack-ai#4610 | `notification.test.ts:78` | "does not re-expose the bare
Notification/NotificationConfig names from ./ui — the bare
`Notification` belongs to ./api alone" | The `./ui` names were deleted;
`./api`'s `Notification` is the live contract. |
| objectstack-ai#11027 | `page.test.ts:494` | "PageComponentSchema — retired
`responsive`, which no renderer read" | Maintainer ruling B
(2026-08-22): retire it, since its renderer hook had zero callers. |
| `ui#6206-B`, objectstack-ai#15442 | `page.test.ts:696` | "ElementDataSourceSchema
`filter` — one filter orthography platform-wide, the ViewFilterRule
array" | Ruling B on objectui#6206 (one orthography), and ruling A on
objectstack-ai#15442: the binding-level `dataSource.filter` converges on
`ViewFilterRule[]`. |
| objectstack-ai#4413 | `react-blocks.test.ts:92` | "REACT_BLOCKS — the record:*
family is out, since no renderer read the props it published" |
`ebb209c`: the `record:*` blocks are withdrawn from the react tier. |
| objectstack-ai#11284 | `react-blocks.test.ts:147` | "REACT_BLOCKS — vocabulary
converges on the metadata tier, and the ListView alias retirement" |
`5383fa6`: the react tier adopts the metadata-tier spelling. objectstack-ai#14791 in
the same literal is dropped: the title names its retirement. |
| objectstack-ai#3916 | `report.test.ts:334` | "Report ordering — a report declares
its own sort" | `f752ee3`: the time axis is ordered by default, and a
report gets its own sort declaration. |
| objectstack-ai#13855 | `section-group-reference.test.ts:86`, `:181` | "… the
field-group reference form, members derived from the group" | Maintainer
ruling B (2026-08-31): a section names a field group and inherits its
members through `deriveFieldGroupLayout`. |
| objectstack-ai#5011 (and objectstack-ai#4001) | `strictness-batch14.test.ts:206` | "dashboard
compareTo: no longer a union but the executor contract — the strictness
arm-error limit does not apply to it" | Maintainer ruling 2026-08-04:
`compareTo` converges on the executor's `{ kind, dimension? }`. objectstack-ai#4001
becomes its subject, the strictness campaign. |
| objectstack-ai#5074 | `view-authoring-wire-split.test.ts:105` | "the two doors,
which is the whole point of the split: strict at authoring, reopened on
the wire" | Maintainer ruling A (2026-08-04): a strict authoring shape,
and a reopened wire member in the union. |
| objectstack-ai#19514 | `view-filter-rule-value-shape.test.ts:249` | "the scalar arm,
in both directions: a single-valued operator refuses an array" | The
protocol half of objectui#9050's ruling C′. |
| objectstack-ai#5114, objectstack-ai#5074 | `view-filter-rule-wire-id.test.ts:107` | "a
console-written filter row, judged per door: refused by name when
authored, stripped on the wire" | objectstack-ai#5114's provisional reopen ended when
objectstack-ai#5074's split landed. |
| objectstack-ai#15811 | `view-form-features-root.test.ts:208` | "an AST-only envelope
no longer reaches this scanner — an evaluated slot requires a `source`,
so it is refused one layer up" | Ruling A (decision batch objectstack-ai#122): every
engine-evaluated expression slot requires a non-blank `source`. |

**Subject back in words** (22 literals): "objectstack-ai#5056 premise" becomes "the
derived-clone bridge premise"; "the objectstack-ai#5068 props gate" becomes "the props
gate"; "the objectstack-ai#19331 shape" becomes "as its form row writes it"
(`object.form.ts`'s labelled `sharingModel` select); "the producer call
shape objectstack-ai#6761 needs" becomes "… the dataset compiler needs"; "the one
departure objectui#3907 did NOT touch" becomes "… the objectui map-limb
fix did NOT touch"; "the calls that caused objectstack-ai#6761" becomes "the calls
behind the dropped dataset label"; "retired at objectstack-ai#4988" becomes "retired
with the interaction-config family"; "after objectstack-ai#4988" becomes "after the
family retirement"; "objectstack-ai#4738 left it to ./ui alone" becomes "the
connector-side rename left it to ./ui alone"; "the objectstack-ai#4610 note" becomes
"the tombstone note"; "(objectstack-ai#5015 took the other half)" becomes
"(EmbedConfig, the other half, was retired)"; "objectstack-ai#4721, the silently
REVERSED sort" becomes "it once parsed as a silently REVERSED sort"; the
four `objectstack-ai#5599 —` titles become "the identity precondition …" / "identity
precondition — …" where the title needs the subject (`:272`, `:278`,
`:298`); "the census record (objectstack-ai#20456)" becomes "the census record of the
keys the console reads back"; "objectstack-ai#6227 — the reported shape" becomes "the
reported shape — a set operator carrying a scalar —"; "recorded as
ACCEPTED at objectstack-ai#6227" becomes "recorded as ACCEPTED by the first
value-shape rule"; "objectstack-ai#6227 — the refinement" becomes "the value-shape
refinement"; "the card's probe … (objectstack-ai#15469)" becomes "the probe that found
the gap"; "the objectstack-ai#14471 typo" becomes "the `colourField` typo", the key
the test writes; "objectstack-ai#6391's union membership" becomes "its union
membership".

**Dropped where already stated** (48 literals, 49 ids). A number goes
only where the title already says its decision. Examples: the three
`objectstack-ai#20080 §2` / `§3` / `§4` prefixes (the `§n` markers stay: the file's
own header numbers its sections with them); "[objectstack-ai#19920] InlineAction is an
inline action body, not unknown"; "… the retired arms are refused with
the prescription (objectstack-ai#20221)"; "InlineActionSchema — `bodyExtra` is the
payload key, `params` is not (objectstack-ai#5777)"; "ListView: objectName / viewType
are RETIRED — … (objectstack-ai#14791)"; the six `objectstack-ai#5074 —` prefixes beyond the first;
the four `[objectstack-ai#7741]` / `objectstack-ai#5114 —` prefixes; "what stays accepted (the objectstack-ai#5685
side: never stricter than the runtime)", which keeps "(never stricter
than the runtime)", objectstack-ai#5685's ruling in words. The batch labels `批 14`, `批
16` and `(batch 13)` stay in the earlier stages' form, and `ADR-0089
D3a` stays as a decision-record citation.

**No file is renamed.** `view-gantt-tree-config-closed-15469.test.ts`
keeps its name; its four title strings are rewritten.

## Readers

- **Test-name filters:** none. No tracked script, workflow or package
config passes `-t` / `--testNamePattern` (the 31 hits are `mapfile -t`,
`docker build -t`, `type -t`, a `create-objectstack -t` template flag
and a self-test's probe strings).
- **Snapshots:** none. No `__snapshots__` directory is tracked under
`packages/spec`, and none of the 29 files calls a snapshot matcher.
- **Projects:** `master-detail-detail-sort-field-retirement.test.ts` is
in the `repo` project (`packages/spec/vitest.repo-tests.json:50`); its
base and head runs below include it. The other 28 run in `local`.
- **By substring:** every old literal, its id-bearing fragment and a
window around each id (283 needles) was searched with `git grep` at the
base, across the tracked tree outside its own file. No gate, doc,
filter, snapshot, QA checklist entry or `scripts/check-*.mjs` self-test
reads one. The 4 hits are two code comments that quote the
`page.test.ts:494` title verbatim: `ui/dashboard.test.ts:585` and
`ui/responsive.test.ts:14`, both reading ("[objectstack-ai#11027] PageComponentSchema
— retired `responsive`"). Code comments are not this card's share. The
new title keeps "PageComponentSchema — retired `responsive`" as its
prefix, so a reader following either comment still finds it.

## Text-only proof

Stage 10's scratch tool (`textonly10.cjs`, md5
`d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file
on three legs:
1. **Skeleton:** the full AST, with string pieces masked. It must be
identical.
2. **Comments:** every comment, byte-equal.
3. **Strings:** each changed string leaf must sit in a test-call title
position or on a declared line, must carry a tracker id before, and must
carry no `#` plus digits after. This stage declares five lines:
`door-reachability.testkit.test.ts:216`,
`form-layout-inline-grid-retired.test.ts:61`, `i18n.test.ts:167`, and
`interaction-config-retirement.test.ts:148` and `:189`.

- **Result:** 29 of 29 files SAME on all three legs, with the per-file
counts predicted in writing before the run.
- **Totals:** 94 changed string leaves in 94 literals: 89 titles and 5
declared. The diff's `+` and `-` lines are exactly the 94 planned lines
as multisets, and every file keeps its line count.
- **Controls (14 of 14 as predicted on the first run, on scratch copies,
each anchor hit once):** identifier rename DIFF; numeric literal DIFF;
comment edit COMMENT DIFF; a non-title string given an id VIOLATION; a
rewritten title given a new id VIOLATION; a title that was id-free at
base edited VIOLATION; one title reverted to base SAME; an `it.each` row
given an id VIOLATION; an undeclared expect message changed VIOLATION; a
title re-split into a `+` chain DIFF; a declared expect message reverted
to base SAME; a declared expect message given a new id VIOLATION; a kept
needle edited VIOLATION; a kept needle's id dropped VIOLATION.
- **Templates and tables:** one `.each` title changes,
`view-filter-rule-value-shape.test.ts:255`, a `%s` template (`refuses %s
— …`): its placeholder and rows are untouched, and the printed names
below match the plan. One template-literal title loses only its tail
(`form-layout-inline-grid-retired.test.ts:141`).

**Test counts:** the 29 files were run at the base, in a separate base
worktree, and at the head, with `--project local --project repo`. Both
sides read 858 tests in 29 files, all passed, with the same count and
status sequence per file in 29 of 29. 596 full test names change, and
each changed name equals the base name with the planned replacements
applied (0 mismatches). No full name repeats on either side.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
29 touched files are in it, and no `*.test.ts` at all. The controls
`src/ui/view.zod.ts`, `src/ui/report.zod.ts` and `dist/index.mjs` are in
it.
- In the built `dist/`, two new phrases and an old one each read in 0
files. The control `Unrecognized key` reads in 42.

So this PR publishes nothing, and no changeset is added.

## Verification (at `612375dc0c`)

- `pnpm turbo run build` over all packages: 71 / 71, through the shared
verify lock (`VERDICT command-exit 0`).
- `@objectstack/spec`:
  - `vitest run --project local`: 619 files, 18471 passed, 1 todo.
- `typecheck`: exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 29 group
files, counted by path with `tsc --listFilesOnly -p tsconfig.test.json`.
- `check:generated`: all 15 generated artifacts up to date, against the
`dist/` the build above wrote.
- **Gates:** `dispatch-gates --commands` derived 79 families, the same
set as stage 21, and all 79 exit 0. `--ran` reconciles: 79 derived, 79
run, 0 NOT-MEASURED, 0 UNRUN, every family with its exit code recorded.
The five roster families whose rosters sit under a touched directory
were also run, and each exits 0: `check:meta-url-spelling`,
`check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`
and `check:filter-alias-parity`.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 29 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 29 configured, 0 ignored. No file sets `parserOptions.project`
or `projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 188 changed lines (+94 /
-94).
- A control-byte scan over the 29 changed files finds none.

## `main` since the base

Re-fetched just before this PR opened, `origin/main` was two commits
past the base (`faf8dce482`: objectstack-ai#21917, objectstack-ai#21906). Neither touches
`packages/spec` or any of the 29 files, so `main` was not merged. `git
merge-tree` onto `faf8dce482` is clean, and none of the 8 open PRs
touches any of the 29 files.

## Acceptance notes

- **The two needles** stay, as above. They leave with their source
comments, in the needles' stage.
- **The census base moved by two ids** after the claim: objectstack-ai#21901
(`8e35895832`) rewrote a `why` string in
`system/metadata-form-zod-reconciliation.test.ts`, which now carries 24
ids where it carried 22. It is a ledger value, not a title, and it rides
the `system/` stages.
- **Same-id test titles in this card's later stages** go with those
stages: 34 lines in `packages/spec/src`, for example
`api/api-error-code-type.test.ts:71` ("[objectstack-ai#19920] …"),
`system/i18n-resolver.test.ts:2652` ("(objectstack-ai#5377)"),
`ui/view-metadata-schema.test.ts:215` ("identity precondition (objectstack-ai#5599)"),
`ui/view-strictness-batch18.test.ts:364` ("[RESOLVED at objectstack-ai#5074] …") and
`ui/view-union-diagnostics.test.ts:62` ("[objectstack-ai#6391] …").
- **Same-id test titles in other packages** stay: 38 lines in 9 packages
(`lint` 8, `objectql` 8, `service-analytics` 7, `cli` 4,
`metadata-protocol` 4, `spec/scripts` 3, `plugin-security` 2,
`plugin-sharing` 1, `service-automation` 1), each package's share under
the objectstack-ai#20513 lane children. Three of them cite `objectstack-ai#6262` (`objectql`) and
two cite `objectstack-ai#6206` (`plugin-security`, `plugin-sharing`): those are
objectstack records, different from the objectui records this group
cites.
- **Code comments with live ids** remain in these files and their
sources, for example the `[objectstack-ai#4610]` / `[objectstack-ai#5781]` banners in
`notification.test.ts`, the `objectstack-ai#5056` section headers in
`door-reachability.testkit.test.ts`, and the two comments above that
quote the old `page.test.ts:494` title. Code comments are not this
card's share.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ject does not provision (objectstack-ai#21937)

Fixes objectstack-ai#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 objectstack-ai#21917 (for objectstack-ai#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 objectstack-ai#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 objectstack-ai#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
objectstack-ai#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 objectstack-ai#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 (objectstack-ai#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`
(objectstack-ai#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.
- objectstack-ai#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 objectstack-ai#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
(objectstack-ai#21910's and objectstack-ai#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>
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