Skip to content

fix(runtime,service-datasource): an import over a code-defined datasource is held to its package's ADR-0028 namespace - #21906

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-21889-code-datasource-provenance
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-21889-code-datasource-provenance

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21889
Clause-②: no

An import over a code-defined datasource is now held to the ADR-0028 namespace of the package that declares the datasource, and the draft door answers the prefixed name. This follows triage's direction A (5999047022, which amends 5998041661). There are two halves.

What changed

  • Writer (packages/runtime/src/app-plugin.ts). AppPlugin registers each code-defined datasource through applyProtection (@objectstack/spec/shared), the stamping helper the other load paths use. The datasource is stamped with the id and version of the package body that declares it. The owner is read the way the metadata plugin's artifact door reads it (ADR-0130 D4):
  • Reader (packages/services/service-datasource/src/plugin.ts, getNamespace and its docblock only). The package record is read from the engine registry (registry.getPackage on the objectql service), the store the publish gate reads for the same check (publishPackageDrafts, metadata-protocol). It used to be asked of the metadata service, which holds no package records in any composition. The id comes only from the stamped _packageId: no guess, no fallback store, no second resolution path. The engine is resolved when it is used, like metadata() (the read-at-use posture).

Outside getNamespace, plugin.ts changes two docblock words, so that neither docblock names package reads: the metadata() docblock ("every datasource read below") and the MetadataServiceLike docblock ("datasource definitions"). Nothing under packages/spec, no metadata-door change, and no new error code.

The ruling's pins, at the real doors

Measured at 8e5ff7aa on the showcase's showcase_external, under objectstack dev (pnpm dev -- --fresh -p 38931) and objectstack start (--compile -p 38933, fresh database). Both gave the same readings:

door answer
POST …/external/tables/orders/import {"name":"probe_orders_21889"} 400 EXTERNAL_IMPORT_ERROR "Object 'probe_orders_21889' is missing the package namespace prefix. Rename it to 'showcase_probe_orders_21889' (namespace = 'showcase')." GET /meta/object/probe_orders_21889 answers 404.
POST …/external/tables/orders/draft 200, name: 'showcase_orders', no TODO(namespace) in the source
import {"name":"showcase_probe_orders_21889"} (control) 201, and GET /data/showcase_probe_orders_21889 answers 200 with 4 rows
import with no name override (the carry) 201, saved as showcase_customers
PATCH / DELETE / PUT /api/v1/datasources/showcase_external (control) 400 DATASOURCE_ADMIN_ERROR / 400 DATASOURCE_ADMIN_ERROR / 405 METHOD_NOT_ALLOWED, unchanged
POST …/external/validate (control) 200, ok: true, over showcase_ext_customer and showcase_ext_order
GET /data/showcase_ext_order (control) 200, 4 rows

Clause-② readings

  • GET /api/v1/meta/datasource, showcase_external, after the change (dev and start alike): _diagnostics, _packageId, _packageVersion, _provenance, active, autoConnect, config, driver, external, label, name, origin, schemaMode. The values are com.example.showcase, 0.1.0 and package. Before the change the item carried none of the three (the writer ablation below, on the harness). All three are declared on DatasourceSchema (...MetadataProtectionFields). On an item with no protection block, applyProtection writes exactly those three (shared/protection.zod.ts), so no _lock* key and no further key is added.
  • The host default item is unchanged: _diagnostics, config, driver, label, name, origin, with no _packageId.
  • The admin list (GET /api/v1/datasources) is unchanged on dev and start for both items: active, driver, label, name, origin, schemaMode, status.
  • No published entry gains an export. codeDefinedDatasourceOwners is a private method, and artifact-collections.ts is not touched.

Tests

  • packages/runtime/src/app-plugin.datasource-provenance.test.ts (new, 5 cases): the single-package bundle in both datasource spellings, the two-body ADDITIVE packages[] artifact and the option-B artifact (each datasource carries its own body's id and is registered once), and the residual top-level datasource (the artifact's own id, plus the warning). No in-repo example declares a datasource in a multi-package artifact, so the two-body cases are pinned here.
  • packages/services/service-datasource/src/__tests__/external-namespace-reads-engine-registry.test.ts (new, 11 cases): the namespace pins now sit on the store the reader reads.
    • The draft is prefixed and carries no TODO.
    • An unprefixed import name is refused with code + status and the shared validator's own ADR-0028 message, and nothing is saved.
    • A prefixed import name is saved, and an import with no override saves the derived prefixed name.
    • The engine is read at each use, and the start and dev orderings give the same answer.
    • Nothing else resolves a namespace: a package item in the metadata service (seeded with a different namespace) is never read, a datasource with no _packageId or with sys_metadata resolves none, a package with no namespace resolves none, and with no engine the documented fallback holds.
  • external-metadata-read-at-use.test.ts: the package map the metadata fake seeded (a store no composition fills) is removed. Its two namespace cases and the dev ordering's draft line moved to the file above.
  • packages/qa/dogfood/test/external-import-code-datasource-namespace.dogfood.test.ts (new, 7 cases, a real boot of the showcase):
    • Premise: the provenance on GET /meta/datasource/showcase_external.
    • The two ruling pins.
    • Controls: the prefixed import with its rows, the default datasource with no package, the admin service's refusals and its list (the harness mounts no admin routes, so the door's status and code are the dev/start readings above), and validate with the federated read.
  • The carry, as triage accepted it:
    • external-import-saves-like-meta.dogfood.test.ts moves to showcase_dogfood_ext_cust_21788. Its control used to import orders under the remote table's own name, a shape the namespace now refuses. It now imports with no name override and asserts the saved name showcase_orders across a cold boot.
    • external-import-destructive-remedy.dogfood.test.ts moves to showcase_dogfood_ext_cust_21841 and …_v2.
    • external-validate-sees-runtime-save.dogfood.test.ts (landed on main by fix(service-datasource): external validate sees a federated object saved at runtime, with no restart #21875 after this PR branched) moves its imported object to showcase_dogfood_ext_ord_21842. Nothing else in it changes: SAVED goes through PUT /meta/object, which runs no namespace check, and the later assertions pass on the renamed object.
    • A repo-wide grep for other pins of an unprefixed import, or of TODO(namespace), on a datasource whose package resolves found none. The remaining draft tests drive ExternalDatasourceService with an injected getNamespace, and the REST tests mock the service.

Runs at 3ec0e9f6, the current head (it carries merges of origin/main at d2ed5e04 and 374ca5cb). Each ran through the shared verify lock, VERDICT command-exit 0:

  • pnpm --filter @objectstack/runtime test: 328 files passed, 4644 passed, 19 skipped.
  • pnpm --filter @objectstack/service-datasource test: 40 files, 741 passed.
  • typecheck for runtime, service-datasource and dogfood: exit 0.
  • Dogfood, 6 files (the three import files, external-validate-sees-runtime-save, external-validate-start-ordering and showcase-external-autoconnect): 23 passed.

Ablations (one-time; restored by blob hash and an empty git diff HEAD, then rebuilt)

Every leg went through scripts/ablation-replace.mjs (anchor hit and landed) and scripts/ablation-dist-preflight.mjs (the mutation reached dist/, and the restore removed it).

  • W, writer stamp removed (applyProtection(…) replaced by an unstamped copy, runtime rebuilt):
    • The runtime unit file went red, 5 of 5.
    • Dogfood went red, 3 of 7: the premise, the refusal (it answered 201 and saved dogfood_ext_order_21889, the defect itself), and the draft ('orders'). The 4 controls stayed green.
    • The rebuild's DTS step exited 1 on TS6133 (the now-unused applyProtection and owner). The JS was emitted, and the preflight read the stamp absent from dist/index.js.
    • The first attempt was a no-op that the tool refused (the replacement text already occurred inside the anchor). It is void, and the reading above is the second attempt.
  • R, reader reverted to asking the metadata service (service-datasource rebuilt, build exit 0):
    • The new service-datasource unit file went red, 8 of 11.
    • Dogfood went red, 2 of 7: the refusal (201) and the draft ('orders'). The premise and the controls stayed green.
  • A, every body stamped with the top-level manifest's coordinates (source-level suite, no build): the runtime unit file went red, 3 of 5 (additive, option-B and residual). The two single-package cases stayed green.

Gates

All at 3ec0e9f6.

  • node scripts/pm/dispatch-gates.mjs --ran: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN.
  • 73 commands exit 0: those 69, the full pnpm lint (eslint . --no-inline-config), and the three PR-context checks run against this PR (check-closing-target-claim, check-partof-closing-keyword, check-single-claim-paths).
  • The dispatch's whole list (134 commands) last ran at f7d3aac1, and every one exits 0. Two of them (check:dual-build-cjs-loads, check:published-readme-exports) exit 0 only after a workspace build cleared their prerequisite.

Acceptance notes


Generated by Claude Code

claude added 6 commits October 5, 2026 17:31
…s package, and the import reads that package's namespace from the engine registry

AppPlugin stamps each code-defined datasource through applyProtection with
the id of the package body that declares it. The federation service's
getNamespace reads the package record from the engine registry, the store
the publish gate reads, instead of the metadata service, which holds no
package records in any composition.

Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-Authored-By: Claude <noreply@anthropic.com>
…and the namespace read from the engine registry

The namespace pins move off the metadata fake's seeded package map, a
store no composition fills, onto the engine registry the reader now reads.

Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-Authored-By: Claude <noreply@anthropic.com>
… showcase's code-defined datasource

The two existing import files move to showcase_-prefixed names, and the
saves-like-meta control imports with no name override under the prefixed
name the draft derives.

Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-Authored-By: Claude <noreply@anthropic.com>
…he harness composes without its routes

Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-Authored-By: Claude <noreply@anthropic.com>
…efined datasource namespace

Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
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 2 package(s): @objectstack/runtime, @objectstack/service-datasource, touching 3 documentable anchor(s).

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

  • content/docs/kernel/services-checklist.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/permissions/capabilities.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/permissions/system-context.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/plugins/index.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/plugin-spec.mdx (via AppPlugin (symbol, a top-level class))

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

  • content/docs/releases/v15.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via AppPlugin (symbol, a top-level class))

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
  • 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 — 26 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 cab639671528ef6f3a201e8995794378a4a28bfe → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9b3d282bb860ba3392a1f3d06c16615f06b1f82a — the merge of head 3ec0e9f677b82213be9a50699d235bbd394e5a11 into base cab639671528ef6f3a201e8995794378a4a28bfe, 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 9b3d282bb860ba3392a1f3d06c16615f06b1f82a && git checkout 9b3d282bb860ba3392a1f3d06c16615f06b1f82a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cab639671528ef6f3a201e8995794378a4a28bfe 3ec0e9f677b82213be9a50699d235bbd394e5a11 && git checkout -B drift-repro cab639671528ef6f3a201e8995794378a4a28bfe && git merge --no-ff 3ec0e9f677b82213be9a50699d235bbd394e5a11

node scripts/docs-audit/affected-docs.mjs --json cab639671528ef6f3a201e8995794378a4a28bfe

⚠️ 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 cab639671528ef6f3a201e8995794378a4a28bfe → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 4 commits October 5, 2026 18:30
… imports under the showcase prefix, and the metadata() docblock no longer names package reads

The import over showcase_external in the validate pin is held to the
showcase package's ADR-0028 namespace, so it imports a showcase_-prefixed
name. Package records are read from the engine registry, so the metadata()
docblock names only datasource reads and the catalog write.

Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-Authored-By: Claude <noreply@anthropic.com>
…names package definitions

Package records are read from the engine registry, so the metadata
service members this plugin reads are datasource definitions and the
catalog write.

Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Five checks on head 3ec0e9f6 were cancelled because no runner picked them up. No test failed, and nothing here is this PR's to fix. Read by the domain:cli seat (session_01RWZbGvPFcRKvUqASZtunCU) at 2026-10-05T21:19Z.

  • Which checks, and why:
    • Test Core, the rollup (CI run 37361292771). All six shards ran and passed at 19:09–19:43Z. The rollup job then sat unacquired and was cancelled at 19:58Z.
    • The four body-reading gates that re-ran on the PR-body edit at 19:48:40Z: The card this PR closes must claim this branch, Part-of PR must not also close its card, No other open PR may claim the same issue and Check Changeset (runs 37365791677, 37365791576, 37365791548, 37365791444). Each was cancelled at 20:03:43Z.
    • In each case the job carries no runner name, and the check annotation reads "The job was not acquired by Runner of type hosted even after multiple attempts". Every one of these checks already passed on this same head in its 19:09Z run.
  • What is needed: one "Re-run failed jobs" on those five runs. The rest of CI on 3ec0e9f6 is green, with expected skips only.
  • Why the seat does not do it: its write channel (the fleet relay) has no workflow re-run op, and it writes nothing through other channels. ⛔ No empty commit and no close/reopen to kick CI.

The seat keeps this PR watched. Once these five are green, it runs the landing pre-checks and lands.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 6, 2026 00:10
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 6, 2026 00:10
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit faf8dce Oct 6, 2026
62 of 67 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21889-code-datasource-provenance branch October 6, 2026 00:53
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
…asses the explicit system opt-in instead of no principal (objectstack-ai#21940)

Fixes objectstack-ai#21913
Clause-②: yes (widening)

This is a slice of objectstack-ai#21908: the services-lane producers. objectstack-ai#21908 stays
open, because it builds the deny itself, last.

## What changes

Every engine call in the card's named functions now passes the explicit
system opt-in that exists today: `{ isSystem: true }` on the call's
context. These calls used to reach the data engine with no context at
all, so they had no principal and no opt-in. They got past the security
middleware only through its principal-less hand-off (ADR-0096 E1), which
objectstack-ai#21908 retires. This PR adds no new elevation API, changes nothing any
door authorizes, and does not build the deny.

| Row | Package | Function | Engine calls that now carry the opt-in |
| :-- | :-- | :-- | :-- |
| 7 | service-settings | `SettingsService.loadRows` | `find` on
`sys_setting` |
| 8 | service-settings | `SettingsService.upsertRow` | existence-probe
`find` and `insert` on `sys_setting` (its `update` already had the
opt-in) |
| 8 | service-settings | `buildSettingAuditWriter` `write` | `insert` on
`sys_setting_audit` |
| 11 | service-datasource | `loadDatasourceRows`, `loadDatasourceRow` |
`find` / `findOne` on `sys_metadata` |
| 11 | service-datasource | `persistDatasourceRow`,
`deleteDatasourceRow` | `findOne` + `insert` / `update` / `delete` on
`sys_metadata` |
| 11 | service-datasource | secret binder `bind` / `unbind` / `resolve`
| `insert` / `delete` / `find` on `sys_secret` |
| 12 | plugin-webhooks | `AutoEnqueuer.doRefresh` | `find` on
`sys_webhook` |
| 12 | plugin-webhooks | `createWebhookRedeliverGuard` | `findOne` on
`sys_webhook` |
| 13 | service-messaging | `SqlNotificationOutbox.claim` / `claimDigest`
/ `reapExpired` | candidate `find`, claiming `update`, read-back `find`;
the reap `update` |
| 13 | service-messaging | `SqlHttpOutbox.claim` / `reapExpired` |
candidate `find`, claiming `update`, read-back `find`; the reap `update`
|
| 14 | service-messaging | `MessagingService.writeEvent` | `insert` on
`sys_notification` |
| 14 | service-messaging | inbox channel `send` +
`writeDeliveredReceipt` | `insert` on `sys_inbox_message`, the
recipient-locale `findOne` on `sys_user` (a helper only `send` calls),
`insert` on `sys_notification_receipt` |
| 14 | service-messaging | `PreferenceResolver.loadRows` | both `find`s
on `sys_notification_preference` |
| 14 | service-messaging | `RecipientResolver.resolveEmail` | `findOne`
on `sys_user` |

IDataEngine reads pass the opt-in in the trailing options argument,
which is where the contract puts a read's context. Two package-local
surfaces have a single options bag, and the opt-in goes there:
`SettingsEngine`, and the `sys_secret` binder's engine slice.
`SettingsEngine.find` and `.insert` and `SecretStoreEngineLike.delete`
now declare the `context` they receive. No symbol is new on any package
entry. The shared constants (`FAN_OUT_SYSTEM_CONTEXT`,
`DISPATCHER_SYSTEM_CONTEXT`) live in package-internal modules.

Rows 15 and 16 are not in this slice and wait for the maintainer.

## Measurement

**Instrument (H2).** A local, uncommitted instrument sat at the security
middleware. It recorded each principal-less, non-system context that
reached the hand-off, with its stack. It recorded whether any of the six
gates before the hand-off threw on such a call, and which of them
matched the call's object and verb. It also recorded the outcome after
`next()`: the result type, row count, key set, a hash of the
non-volatile values, or the error code. In the AFTER leg it recorded the
same outcome for each `isSystem` call whose stack ran through these four
packages. Both legs covered the whole dogfood suite (206 files, 1590
tests passed, 9 skipped, identical in both legs) and a booted showcase
dev composition. The boot covered seed-admin, a settings read plus two
writes, a runtime datasource create / patch / read / delete, and admin
and anonymous requests, then sat idle for 65 seconds so the dispatchers
and the webhook refresh ticked. The instrument was then reverted, and
the file's blob equals HEAD (`5b4ab28045af`). The plugin-security dist
was rebuilt clean: `ablation-dist-preflight --absent` passes, and the
marker had 3 hits in the instrumented dist.

**Before and after, per function.** Columns: principal-less records
BEFORE, principal-less records AFTER, and `isSystem` records AFTER.

| Function | dogfood before / after / after-system | boot before / after
/ after-system |
| :-- | --: | --: |
| `SettingsService.loadRows` | 2090 / 0 / 2090 | 42 / 0 / 42 |
| `SettingsService.upsertRow` (probe + insert) | 7 / 0 / 7 | 3 / 0 / 3 |
| setting-audit `write` | 4 / 0 / 4 | 2 / 0 / 2 |
| `loadDatasourceRows` | — | 1 / 0 / 1 |
| `persistDatasourceRow` | — | 4 / 0 / 4 |
| `deleteDatasourceRow` | — | 2 / 0 / 2 |
| `AutoEnqueuer.doRefresh` | — | 3 / 0 / 3 |
| `SqlNotificationOutbox.claim` | 8 / 0 / 8 | 56 / 0 / 56 |
| `SqlNotificationOutbox.claimDigest` | 8 / 0 / 8 | 56 / 0 / 56 |
| `SqlNotificationOutbox.reapExpired` | 1 / 0 / 1 | 7 / 0 / 7 |
| `SqlHttpOutbox.claim` | 8 / 0 / 8 | 56 / 0 / 56 |
| `SqlHttpOutbox.reapExpired` | 1 / 0 / 1 | 7 / 0 / 7 |
| `MessagingService.writeEvent` | 8 / 0 / 8 | — |
| inbox `send` (row insert) | 8 / 0 / 8 | — |
| `writeDeliveredReceipt` | 8 / 0 / 8 | — |
| `PreferenceResolver.loadRows` | 16 / 0 / 16 | — |
| `RecipientResolver.resolveEmail` | 1 / 0 / 1 | — |

Principal-less totals moved 35245 → 33077 in dogfood (Δ 2168) and 422 →
183 at boot (Δ 239). Each delta is exactly the sum of the rows above. No
principal-less record attributed to any moved function remains. The
hand-off still sees row 15 and every other lane's producers.

No run reached these, so each is held by its unit pin instead:
`loadDatasourceRow`, the secret binder (this repo wires it into no
composition), the redeliver guard, the inbox recipient-locale read
(template path), and the claim path's `update` and read-back (no pending
rows in any run).

**Gates before the hand-off (Zone 1).** Across 35245 dogfood and 422
boot principal-less records, the "gate threw" record fired 0 times. The
package-managed, system-row, curated-capability and audience-anchor
gates never matched an object or verb these producers touch. Neither did
the delegated-administration gate. The engine-owned guard matched the
bucket on the writes to engine-owned objects. On a context with no user
id, its own `isUserContextWrite` predicate returns before it can refuse.
**No producer is held back.**

**What each call answers is unchanged.** Per function, call counts per
object and verb are equal before and after. So are the outcome shapes
(result type, row count, key set). There were 0 errors in either leg.
Content hashes are equal for 11 of 14 functions in dogfood and 7 of 9 at
boot. The rest differ only on values that change every run: the
receipt's `at` timestamp (all 8), and inbox and notification payloads
that carry a per-run record id or date (2 of 8 and 3 of 8, from the
approval and sweep tests). At boot, the probe's own per-phase file path
sits in the stored datasource record. The plugin-audit rows these writes
produce (`sys_audit_log`, `sys_activity`) are written in equal numbers
before and after.

**H6, `loadRows`.** The call count is the same (2090 + 42), and the
returned settings have equal hashes on every call. The opt-in adds one
frozen context object. The middleware now exits at its system
short-circuit instead of running the six gates and the hand-off. No
wall-clock figure is quoted, because the container is shared.

**H7, reads on another principal's behalf.** What these reads return (a
user id for an address, a locale, preference rows) is consumed inside
the fan-out. `emit()` answers the notification id, counts and
per-delivery outcomes. Its three in-repo callers (approvals, the flow
notify node and comment mentions) relay counts and the id only. The
opt-in changes none of this, because the principal-less read returned
the same rows.

**One engine branch keyed on the flag stops running on these writes.**
It is row 23 of the `isSystem` census page: the dangling-reference check
is skipped for an `isSystem` write. Before the move, it ran 10 times
nested under these producers (`writeEvent` 2, inbox `send` 2,
setting-audit `write` 6), on the `actor_id` lookups, and resolved every
time. After the move it does not run. A local probe (real ObjectQL and
SQLite, deleted after the run) showed what that means for an `actor_id`
that names no user. With no context, today's path refuses with
`VALIDATION_FAILED` ("Actor: no sys_user record has id …"). Under
`isSystem` the row is written. A real user is written both ways. That
`actor_id` comes from `emit()`'s `actorId`, which a flow notify node can
author. So the behaviour on measured traffic is unchanged, and a latent
difference remains for an `actorId` that names no user. The Acceptance
notes carry it.

**H4 pins and ablations.** There is one pin per package. The engine
double sits behind the package's real call path, proves the population
ran, and asserts `isSystem` on every call. Each pin was ablated by
dropping the opt-in through `scripts/ablation-replace.mjs` (the anchor
must hit). Seven legs ran: settings `loadRows`, the fan-out constant,
the dispatcher constant, the datasource `sys_metadata` constant, the
secret-binder constant, and the two webhook constants. Every leg went
red under the mutation, and the failure names the call, for example
"find on sys_setting: expected undefined to deeply equal { isSystem:
true }". Every leg was restored with blob equal to HEAD and an empty
`git diff HEAD`, and went green again. The pins are package-local,
imported from `src` with no dist in the path.

**Census pages (H3).** The `isSystem` census
(`check-system-context-census`) is OK, and `--fix` changed nothing: this
change adds no elevation read site. The tenant-audit census did move,
because the write sites now thread a context. It was regenerated with
`tenant-audit-census.mjs --write`. On its page, the hand-written figures
follow the census: the provable no-context, tenancy-enabled count went 9
→ 2, unreadable 67 → 60, decidably elevated 114 → 121.

**Serial (H5).** `origin/main` was merged twice. It now includes
objectstack-ai#21906's squash, and the merge was clean. A `git merge-tree` against
objectstack-ai#21877's head (`5c405846`, now closed as a draft) is clean. This PR
edits neither PR's region: `datasource-admin-plugin.ts` and
`datasource-secret-binder.ts` only, in `service-datasource`.

## Tests

- At `10e77fef6f`, after merging `origin/main` `faf8dce482`. The next
merge (`9dce635337`, which brings this PR to `62960ffa1a`) touches no
file in these four packages. Typecheck of the four packages: exit 0.
- Unit suites: service-settings 614 passed, service-messaging 510,
service-datasource 743, plugin-webhooks 165. All exit 0, unchanged apart
from the new pins and tests that came in from `main`.
- ESLint, narrowed to the 19 changed TS files with `--no-inline-config
--format json`: 19 files, 0 errors, 0 warnings. Those files are inside
the config's own `packages/**/*.{ts,…}` population, and the config
enables no type-aware linting, so this diff cannot move a verdict on any
untouched file. The full `pnpm lint` run belongs to CI.
- At `62960ffa1a`, the head this PR opens with, every one of the 105
commands `dispatch-gates --commands --repo objectstack-ai/objectstack`
derives exited 0. `dispatch-gates --ran` reports: "105 derived
famil(ies) accounted for — 105 run, 0 NOT-MEASURED". In an earlier pass,
four of these went red on this branch, and they are now fixed.
`check:tenant-audit-census` needed the census regenerated.
`check:engine-double-contract` and `check:objectql-double-limit` needed
the pin doubles routed through the shared dispatch asserts and holding a
find's bound, with the ledger recording the new pinned coverage.
`check:dual-build-cjs-loads` needed eight unrelated packages built
first.

## Acceptance notes

- **Producers in these packages that the card does not name.** A static
read finds that they still reach the engine with no context. No run
exercised them, so the measured table never listed them. Without a
route, objectstack-ai#21908's deny breaks each one, so they are listed for the seat's
closure rather than moved here:
- service-settings: the `sys_secret` store the plugin builds (`insert` /
`get` / `update`), and `SettingsService.readStoredHandle`.
- service-messaging: `SqlNotificationOutbox` and `SqlHttpOutbox`
`enqueue`, `ack` and `list`; the email and SMS channels' recipient
reads; `RecipientResolver.resolveRole` / `resolveTeam` /
`resolveOwnerOf`; the emit dedup lookup; the template renderer's read.
- `resolveOwnerOf` reads a business object, and its posture is not
neutral. Today the sharing middleware answers a principal-less read of a
`private` object with a deny-all filter, so an `owner_of:` recipient on
such an object resolves to nobody. Under the opt-in, that filter would
be bypassed.
- **Request-door producers that act on the caller's own rows, like rows
15 and 16** (report-only, for the maintainer's ruling): the inbox unread
count, and mark-read / mark-all-read (`unreadNotificationIds`,
`upsertReadReceipt`, `notificationOrganization`).
- **The row-23 difference above:** the dangling-reference check stops
running on the `actor_id` of `sys_notification`, `sys_inbox_message` and
`sys_setting_audit`.
- One posture question was noted on a request-door read and is held
off-thread. It was not measured.

## Seat's append: patch round 1 at `57f738dfb1` (written by
`domain:services` seat 1 from the dev's report `6009307655`; the dev
does not edit this body)

**What changed in the patch round** (seat verdict `6008259054`). The
sections above describe `62960ffa1a`; where they differ, this append is
current.
- **`Clause-②: yes (widening)`.** The exported `SettingsEngine` (`find`,
`insert`) and `SecretStoreEngineLike` (`delete`) gain an optional
`context`, so `@objectstack/service-settings` and
`@objectstack/service-datasource` take a `minor`. `service-messaging`
and `plugin-webhooks` stay `patch`. Line 2 above, the changeset and the
claim (`6003840075`) moved together. Nothing accepted or refused at any
door changes.
- **A user reference that names no user is still refused.** The engine
skips its dangling-reference check for an `isSystem` write and has no
option to keep it. So each producer that writes a user reference does
one guarded `sys_user` read by id under the opt-in, then refuses an
unknown id with the engine's own answer: `VALIDATION_FAILED`, one
`reference_not_found` finding, and the same message.
- The checked references are the `actor_id` of `sys_notification`
(`writeEvent`), of `sys_inbox_message` (the inbox send) and of
`sys_setting_audit` (the setting-audit writer), and the `user_id` of a
user-scope `sys_setting` row on `SettingsService.upsertRow`'s insert.
The last is the same difference, which this PR's opt-in introduced on
that insert.
- The refusal is built by `validationFailure` from `@objectstack/types`,
already a runtime dependency of both packages, so neither package stamps
the code itself and `check:error-code-provenance` is green with no spec
row and no waiver. It is shape-identical to the engine's refusal but not
`instanceof` objectql's `ValidationError`; the callers on these paths
read the message or the code, and every door maps the shape to `400
VALIDATION_FAILED`.
- A write that names no user is unchanged. A read that cannot run lets
the write through, as the engine's check does. The cost is one extra
`sys_user` read per write that names a user.
- Differential pins over a real engine hold each producer's answer equal
to the engine's own refusal of a context-less insert. Four ablations
went red and were restored with blob equal to HEAD.
- **objectstack-ai#21935 merged in** (`a3c2209a68`). `check:pm-dispatch-gates` exits
0.
- **Gates at `57f738dfb1`:** 105 derived, 105 run, all exit 0. The 54
roster families: 51 exit 0, and 3 are NOT WIRED locally (they need a
pull-request context; CI runs them).
- **`service-settings/vitest.config.ts`** gains one anchored alias
(`platform-objects/identity` → `src`) for the new pin, which
`check:test-source-alias` asks for.

**Carried, not filed here:**
- Producers in these packages that the card does not name are recorded
on objectstack-ai#21908's census (rows 24 onward). `resolveOwnerOf` is not neutral to
move.
- The inbox unread count and mark-read / mark-all-read join the
maintainer's open ruling on rows 15 and 16.
- One request-door posture question is held off-thread, at class level
only.

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

---------

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

Projects

None yet

2 participants