Skip to content

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

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

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

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21918
Clause-②: no

What was wrong

Deleting a record runs the engine's referential cascade (ObjectQL.cascadeDeleteRelations), which probes every registered lookup / master_detail field that references the deleted object. The registry injects its own columns into every object, federated (ADR-0015 external) ones included: the tenant anchor organization_id, the ADR-0117 D1 anchor owning_business_unit_id, the owner owner_id, and the audit lookups created_by / updated_by. The platform provisions no storage for a federated object, so none of them exists on the remote table.

PR #21917 (for #21910) taught the scan to skip organization_id alone, through isFederatedInjectedTenantAnchor. The scan still probed the remote table on the other anchors. The SQL driver refused the unknown column (INVALID_FILTER), the probe's catch propagated it as #8895 rules, and the delete failed:

  • an admin's DELETE /api/v1/data/sys_business_unit/:id answered 400 (INVALID_FILTER on showcase_ext_customer.owning_business_unit_id);
  • removing a user answered 500 (the same refusal on showcase_ext_customer.created_by, raised inside better-auth).

What changed

The producer side is ruled (#7865 direction B keeps the injection and supplies the marker this reads), so the fix stays in the engine's readers. No packages/spec edit.

The enumeration pin (the closing act)

packages/objectql/src/federated-injected-column-readers.test.ts scans every non-test source of @objectstack/objectql with the TypeScript parser for every use of a named seam:

  • the federated decisions and the provenance they read;
  • the relation-carrier arbiters (referenceCarrierOf, referenceTargetOf);
  • the tenant-column resolver and its constant;
  • every spelling of an injected column's name.

The names are not listed. They come from injectedSystemColumnDefs (@objectstack/spec/data), the table the registry spreads. Each use is keyed FILE#FUNCTION :: SEAM, and every key must have a row in a closed-disposition table, while every row must still be found. A row that says the site asks a federated predicate is checked against the source: the site calls it, or calls the named same-file helper that does. Why a scan and not a registry the readers call into: the readers that failed in this family did not know the question existed, so they would never have registered. A scan finds them by the seam they cannot avoid.

The 63 seam uses today, by disposition:

Disposition Sites
skips (asks the general predicate) cascadeDeleteRelations, planCascadeAtomicity, lifecycle tenantWindowsFor (and reap / archiveObject through it)
exempt (asks isFederatedObject) buildDriverOptions, the related-record read (resolvePredicateRelated), resolveSystemInsertOrganization
excludes (reads the provenance) the dangling-reference audit (auditableReferenceFields, organizationFieldOf)
row-value eventOrganizationId reads the written row; a federated row has no tenant column, so the key is omitted
target-by-id assertReferencesResolve, expandRelatedRecords, resolveRelatedTitleTarget
caller-predicate relation-filter lowering, five validation-rule sites
author-declared buildSummaryIndex (a roll-up's FK inference; see Acceptance notes)
policy-subject lifecycle created_at (the age a retention / archive selects by)
writer the audit hook's created_by / updated_by stamping
not-a-read / definition name vocabularies, sync routing, injection constants, refusal text, the predicate and resolver themselves

Pins

  • packages/objectql/src/federated-object.test.ts (5): the general predicate accepts every injected anchor of a registered federated object. It agrees with unprovisionedInjectedColumns on every field of three objects. It refuses an author-declared organization_id, owner_id and lookup (author), and every injected column of a local object (injected-provisioned). It also refuses id and inputs that are not objects.
  • packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts (finding(objectql): deleting an organization answers 500 when a federated object is provisioned, because the cascade scan probes the remote table on the platform-injected organization_id #21910's 5 pins kept, 7 added, through engine.delete on a two-driver engine; the existing stub driver now also logs which columns each read filters on):
    • a business-unit delete never reads the federated object, and a local object's owning_business_unit_id IS probed (control);
    • a user delete never reads it, and the local owner_id / created_by / updated_by ARE probed;
    • author-declared unit_ref and owner_id on a federated object are still probed, and only those columns are. Their failure propagates with its envelope (INVALID_FILTER, 400, the same error object);
    • both plans run one transaction when injected anchors were the only cross-datasource references, and an author lookup keeps both plans split with one warning.
  • packages/objectql/src/lifecycle/lifecycle-service.test.ts (4 added): a federated object's reap and archive run one global pass and never filter on organization_id (the double refuses any read naming it). The same declaration on a local object, and an organization_id the author declared on a federated object, keep their per-tenant partition.
  • The enumeration pin (5).
  • Door pins, packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts. They boot the showcase with orgContext, provision the fixture with onEnable in the test's own mkdtemp directory, and assert the premises on the same boot: the remote rows are served, each anchor is injected-unprovisioned, and a SYSTEM read filtered on each one is refused INVALID_FILTER. Then:
    • DELETE /api/v1/data/sys_business_unit/bu_21918 answers 200, the row is gone, and the federated rows are untouched;
    • POST /api/v1/auth/admin/remove-user answers 200, the user row is gone, and the federated rows are untouched.
  • finding(objectql): deleting an organization answers 500 when a federated object is provisioned, because the cascade scan probes the remote table on the platform-injected organization_id #21910's door pin (organization-delete-federated-fixture.dogfood.test.ts) stays green.

The user-delete door, and a harness gap

The only HTTP door that deletes a user is better-auth's POST /api/v1/auth/admin/remove-user, which plugin-auth mounts when the better-auth admin plugin is on.

  • DELETE /data/sys_user/:id answers 405 by design (ADR-0092), and /auth/delete-user is unconfigured (404).
  • objectstack serve turns the admin plugin on by default (OS_AUTH_ADMIN, packages/cli/src/commands/serve.ts). The verify harness constructs AuthPlugin with no plugin options, so the route answers 404 there. That is the 404 finding(objectql): deleting an organization answers 500 when a federated object is provisioned, because the cascade scan probes the remote table on the platform-injected organization_id #21910's dev measured.
  • The harness exposes no auth option. The door pin turns the plugin on through OS_SCIM_ENABLED, the one switch the harness reads that does (ADR-0134), the same knob admin-credential-lifecycle.dogfood.test.ts uses.
  • The vendor route authorizes on the legacy sys_user.role === 'admin' scalar, which ADR-0068 D2 retired. So a platform admin is refused there with 403 YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS, a ruled state.
  • The pin writes that scalar onto the admin row, as plugin-auth's remove-user-atomicity.test.ts does, because its subject is the cascade and not the route's authorization.

Measured readings (showcase with the federated fixture, own temp dir)

Door Before (f243a29290) After
admin DELETE /data/sys_business_unit/:id 400 INVALID_FILTER on owning_business_unit_id; log [sql-driver] INVALID_FILTER ... showcase_ext_customer ('owning_business_unit_id') 200
POST /auth/admin/remove-user (admin plugin on, legacy scalar) 500, empty body; log INVALID_FILTER ... ('created_by'), better-auth SERVER_ERROR; user row survives 200, row gone
same route, platform admin without the scalar 403 YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS (ruled, unchanged) unchanged

Atomicity plan (planCascadeAtomicity on the booted showcase): organization, business unit and user all read split before and atomic after. The only non-default-driver participants were the two federated objects, reached through injected anchors. Scan and plan agree, measured after: the set of objects the scan probed (its [reference-cleanup] record, filed once per probed child) equals the plan's first-level participant set: organization 53 = 53, business unit 27 = 27, user 66 = 66, with no federated object in any.

Reverse verification (committed HEAD 1a131e4b4b, every mutation through scripts/ablation-replace.mjs)

Gates (at 7c2888a239, after merging origin/main faf8dce482)

  • node scripts/pm/dispatch-gates.mjs --commands over the branch derived the same 71 commands as the dispatch. All 71 exit 0, and --ran reads 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN. check:dual-build-cjs-loads first answered exit 3 PREREQUISITE NOT MET (8 packages had no dist/). After building them it passes (106 entry points across 66 packages).
  • Artifact-roster block: 53 commands; 50 exit 0. check-closing-target-claim.mjs, check-partof-closing-keyword.mjs and check-single-claim-paths.mjs need a PR in their environment (exit 2, NOT WIRED before this PR existed); their CI workflows run them.
  • Symbol-anchor sweeps: check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors, check:adr-anchors: all exit 0.
  • check:objectql-double-limit passes: no new double. The existing stub driver was extended, and its find still applies the caller's limit after the filter.
  • pnpm --filter @objectstack/objectql test (two shards): 378 files, 7495 tests passed. pnpm --filter @objectstack/objectql typecheck: tsc clean, and check:test-typecheck reads OK with no new debt (234 ledgered errors, none in the four touched test files, which tsc --listFiles includes). pnpm --filter @objectstack/dogfood typecheck: exit 0, and it includes the door pin.
  • ESLint, narrowed to the 8 touched code files:
    • population: eslint.config.mjs lints **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} outside NEVER_LINTED;
    • count: --format json reports 8 files, 0 errors and 0 warnings;
    • invariance: the config never enables type-aware linting (no parserOptions.project, no typed rules), so this diff moves no untouched file's verdict. The full pnpm lint is CI's.

Acceptance notes

  • buildSummaryIndex (disposition author-declared, not changed here). A roll-up declared with no relationshipField infers its foreign key from the child's first relation to the parent. Injected anchors point only at sys_organization, sys_business_unit and sys_user, so it can meet one only for a roll-up declared on one of those, over a federated child. Inference, unmeasured.
  • The audit hook is a writer (plugin.ts#registerAuditHooks stamps created_by / updated_by on insert and update, federated objects included). A write to a writable federated datasource would carry columns the remote may lack. Inference, unmeasured: the showcase's federated datasource refuses writes.
  • Lifecycle created_at stays the policy's subject. A federated object that declares retention (or archive without ttl) reaps by created_at, which is the registry's injection there. A remote without it refuses the filter, and the sweep reports the object in errors every sweep. The spec accepts the declaration and lint does not warn.
  • A tenant-scoped retention override naming a federated object selects no rows, as it would on a provisioned object whose rows all carry no organization. The object's sweep now runs its global window instead of failing.
  • The verify harness has no auth plugin options. Its AuthPlugin differs from serve.ts's default (admin on), so vendor admin routes answer 404 under the harness unless a test sets OS_SCIM_ENABLED.

Generated by Claude Code

claude added 5 commits October 6, 2026 01:07
… provision

The cascade scan, its atomicity plan and the lifecycle tenant partition now
ask one predicate, isFederatedUnprovisionedInjectedColumn, which reads the
registry's own provenance (resolveInjectedColumnProvenance) instead of naming
organization_id alone.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…ery injected anchor, and the lifecycle partition

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…e business-unit and user delete doors

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 6, 2026
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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

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

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

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

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

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

Which tree this was computed on

This run read content/docs from 2ab3b0073b37a2d709d5d58ef4183628665f8ef3 — the merge of head 85098a49af592a66fec2048e93c0fc97186905dc into base 9dce635337c2cc42a4149aa49289ad77d172363d, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2ab3b0073b37a2d709d5d58ef4183628665f8ef3 && git checkout 2ab3b0073b37a2d709d5d58ef4183628665f8ef3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9dce635337c2cc42a4149aa49289ad77d172363d 85098a49af592a66fec2048e93c0fc97186905dc && git checkout -B drift-repro 9dce635337c2cc42a4149aa49289ad77d172363d && git merge --no-ff 85098a49af592a66fec2048e93c0fc97186905dc

node scripts/docs-audit/affected-docs.mjs --json 9dce635337c2cc42a4149aa49289ad77d172363d

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT (seat review) — PR #21937 at head 7c2888a239

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

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

Generated by Claude Code

Each of its three scanning tests re-parsed the package source and climbed
from every node to the root to name its site: about 1.5 s per scan
unloaded. Under the CPU contention a Test Core shard runs at, two of them
measured past vitest's 5 s default timeout. The walk now hands the enclosing
site down, the scan is computed once per run, and the tests that may pay for
it declare an explicit budget. The READERS table is unchanged, and the pin's
own exact-set assertions hold against it.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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

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

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

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 6, 2026 03:11
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 6, 2026 03:11
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit 13a22d0 Oct 6, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21918-federated-injected-anchors branch October 6, 2026 03:43
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…cision in words instead of a tracker number (stage 23) (objectstack-ai#21947)

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

Stage 23 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 last name-ordered `ui/` group: the 16 id-bearing
test files directly under `packages/spec/src/ui/` from
`view-item-config-type.test.ts` to `widget.test.ts`. Those files carried
100 messages and 106 tracker ids, citing 53 records. All 106 now either
state what their record decided, in words (form D), or are dropped where
the title already says it. No needle sits in this group. Text only: no
assertion, identifier, test count or code comment changes, and no file
is renamed.

## Census at the base (`9e33ee7c59`)

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 22 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 `9e33ee7c59`, the claim's
base. Both instruments read **571 messages / 604 ids in 127 files**, the
seat's reading and stage 22's head reading.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `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 |
| `ui/` (this PR: 16 of the 21 files) | 21 | 107 / 113 | 97 / 103 | 10 /
10 |
| `ai/` | 1 | 2 / 2 | 0 | 2 / 2 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **127** | **571 / 604** | **523 / 553** | **48 / 51** |

The group reads **100 messages / 106 ids in 16 files**, the seat's
figures file for file:

| file (under `ui/`) | messages / ids | titles | other |
|:--|--:|--:|--:|
| `view-item-config-type.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `view-metadata-schema.test.ts` | 8 / 8 | 8 / 8 | 0 |
| `view-metadata-type.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `view-overlay-options-bag.test.ts` | 6 / 6 | 6 / 6 | 0 |
| `view-overlay-options-type.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `view-overlay-owner-hidden-retirement.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `view-overlay-viewkind-arm.test.ts` | 10 / 10 | 10 / 10 | 0 |
| `view-overlay-viewkind-type.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `view-strictness-batch18.test.ts` | 10 / 11 | 10 / 11 | 0 |
| `view-submit-redirect-url.test.ts` | 5 / 5 | 5 / 5 | 0 |
| `view-union-branch-focus.test.ts` | 7 / 7 | 6 / 6 | 1 / 1 |
| `view-union-diagnostics.test.ts` | 4 / 5 | 4 / 5 | 0 |
| `view-union-retirement-prescription.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `view.test.ts` | 39 / 43 | 38 / 42 | 1 / 1 |
| `widget-i18n-retirement.test.ts` | 3 / 3 | 2 / 2 | 1 / 1 |
| `widget.test.ts` | 1 / 1 | 1 / 1 | 0 |
| **16 files** | **100 / 106** | **97 / 103** | **3 / 3** |

Three more test files sit in the same name range and carry no id
(`view-item-owner-hidden-retirement.test.ts`,
`view-list-tabs-retirement.test.ts`, `vocabulary-derivation.test.ts`).
The three "other" strings are rewritten and declared to the text-only
tool: the table label at `view-union-branch-focus.test.ts:139`, which
prints inside two `for … of` test titles, and the expect messages at
`view.test.ts:4099` and `widget-i18n-retirement.test.ts:135`.

- **Controls.** Lit: `ui/notification.test.ts` and
`api/api-error-code-type.test.ts`, outside the group, read 1 id each at
the base and at the head. Dark: `view.test.ts` reads 0 at the head while
92 of its comment lines still carry a number. Planted in a scratch tree:
an id put into a `widget.test.ts` title reads 1 / 1 (`title:describe`),
and an id put into a `view-metadata-type.test.ts` comment reads 0.
- **A wider pattern** (any `#` plus digits) reads the same as the gate
pattern in 15 of the 16 files at the base. `view.test.ts` reads 2 more,
and keeps them at the head: the CSS colours `'#00cc00'` (`:2770`) and
`'#22c55e'` (`:3537`), fixture values that cite nothing.
- **At the head:** 471 messages / 498 ids in 111 files. The 16 files
read 0 / 0, `ui/` reads 7 / 7, 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 22's re-cut named
this group at 106 ids, and this census reads 106, so no re-cut was
needed. `view.test.ts` (43 ids) is one file inside one text-only proof
here, so it is not split.

**`ui/` after this PR** reads 7 / 7, all kept items: stage 20's
`component-props-unknown-members.pin.test.ts:322`, stage 21's four
colour literals (`dashboard-chart-structure-refusal.test.ts:94`,
`dashboard.test.ts:124`), and stage 22's two needles
(`notification.test.ts:123`, `strictness-batch14.test.ts:395`).

**Named for the next stages** (cut from the head census, 471 / 498):
- **`api/`, 201 ids in 40 files**, with no subdirectory. Its first
name-ordered group near the bound is `ai-agents-envelope.test.ts`
through `package-lifecycle.test.ts`: 27 files, 100 messages / 106 ids
(95 / 101 titles, 5 / 5 other: `auth.test.ts`,
`discovery-environment-subset.pin.test.ts` and three in
`export-job-family-retirement.test.ts`). The second is
`plugin-rest-api.handler-status-retirement.test.ts` through
`zod-issues-to-fields.test.ts`: 13 files, 89 / 95, `protocol.test.ts`
alone 50.
- `system/` 167, two stages. The files directly in `src/`, 120, one.
- The needles: the three docblock needles, the kept `:322` and stage
22's two. One stage, with an at-tier review.

## What each id became

- **25 literals (27 ids)** now state a decision in words.
- **20 literals (22 ids)** get their subject back in words, where the
number stood for a thing.
- **55 literals (57 ids)** drop a number the title already explains.

Every cited record was fetched with all its comments through REST, and
its decision was read from its ruling, ACCEPT and landing comments: 53
records, 51 answer 200 and 2 answer 404. Five citations are objectui's
and were read from objectui: `objectui#5233`, `objectui#2231` (cited
bare at `view-strictness-batch18.test.ts:309`), `objectui#6237` (cited
bare at `view.test.ts:949`), `objectui#5435` and `objectui#3289`. Three
same-number records in the other repository were fetched first and set
aside: `objectstack#2231` is a version-packages PR, `objectstack#6237` a
datasource PR, and `objectui#2998` a form PR; `framework#1894 / objectstack-ai#2998`
are this repository's objectstack-ai#1894 and objectstack-ai#2998 under its old name. The two that
answer 404 were read from what landed:
- **objectstack-ai#9933**, from its landing commit `d5552ca13f` ("admit columnState as
an explicitly runtime-only view-overlay key") and the CHANGELOG entry
for `d5552ca`;
- **objectstack-ai#11195**, from PR objectstack-ai#11458, the PR that closed it
("UserActionsConfigSchema adopts group / hideFields / rowColor (ruled A
on objectui#5435)").

One citation names the wrong record, and the titles now state what
landed instead. `objectstack-ai#3896 close-out` (twice in `view.test.ts`) names objectstack-ai#3896,
the sharing-rule criteria card, which records no decision about these
keys; the two titles state the decision from the landed tombstones of
`form.defaultSort` and `view.responsive` / `view.performance`, as stage
20 did for `action.test.ts`.

Where a record's first decision was corrected later, the title follows
the correction:
- **objectstack-ai#6926:** its first triage direction retired the `groups` alias; the
measurement found live consumers, and the maintainer re-ruled A, a fold
at the producer. The two titles say "the producer-side `groups` fold"
and "folds onto `sections`".
- **objectstack-ai#7025 and objectstack-ai#7741:** objectstack-ai#7025 froze the acceptance face; objectstack-ai#7741's ruling
then moved it, and later retirements moved it again, each pinned. The
title says "frozen by the diagnostics work; every move since is
deliberate and pinned", not "moved only once".
- **objectstack-ai#7510:** the "[objectstack-ai#7510] ⛔ the acceptance face did not move" describe
sits beside two ruled moves recorded in its own comments, so the title
now names what did not move it: "⛔ the branch focusing did not move the
acceptance face".

**Stated in words:**

| record | literal (under `ui/`) | now reads | the decision |
|:--|:--|:--|:--|
| objectstack-ai#5599 | `view-metadata-schema.test.ts:95` | "REJECTS a bare `{}` — the
pin this line used to make, reversed by the identity precondition" |
Maintainer ruling 2026-08-06, direction B: a minimal identity
precondition ahead of the union's four members; each member's `.strip()`
is untouched. |
| objectstack-ai#7741 | `view-metadata-schema.test.ts:125` | "… NO object binding — a
row no read path could serve, with located guidance" | Maintainer ruling
2026-08-12, direction B: a row that cannot be expanded or served by any
read path is not stored and badged valid; the inline arm requires the
binding, refused with `defineView`'s guidance. |
| objectstack-ai#5599 | `view-metadata-schema.test.ts:215` | "identity precondition —
a body must read as a view before any member judges it" | The same
direction B. |
| `objectui#5233` | `view-metadata-schema.test.ts:413` | "… a
`columnState`-only patch (the patch-only write the console persists)" |
Maintainer ruling 2026-08-12 (on objectstack-ai#7494): `persistViewPatch` stores the
patch only, not the merged base. |
| objectstack-ai#17152 | `view-overlay-owner-hidden-retirement.test.ts:335` | "… names
the family's D2 conversion (ruled: a D3 entry per family, even beside a
lossless D2)" | Ruling B (director seat, 2026-09-10, upheld 2026-09-11):
one D3 semantic entry per retired family, beside its D2 conversion even
when D2 is lossless. |
| objectstack-ai#7494 | `view-overlay-viewkind-arm.test.ts:102` | "the console %s
toggle (a patch-only write, as ruled) is ACCEPTED on listOverlay" |
Maintainer ruling 2026-08-12: the overlay store is org-wide, and the
toolbar write stores the patch only. |
| objectstack-ai#4001 | `view-strictness-batch18.test.ts:91` | "批 18, unknown keys
refused — the doors these shapes are reachable through" | The strictness
campaign: an unknown key on the authorable surface is refused, not
silently stripped. |
| objectstack-ai#15469 | `view-strictness-batch18.test.ts:149` | "… a CLOSED entry,
and since the renderer-ahead `.passthrough()` was removed a CLOSED
parent too" | Maintainer ruling A (decision batch objectstack-ai#41, 2026-09-05):
every key the gantt and tree renderers read is declared, and both
`.passthrough()` calls go. |
| objectstack-ai#5074 | `view-strictness-batch18.test.ts:364` | "[RESOLVED by the
ruled split] ViewItemSchema SPLIT — …" | Maintainer ruling A
(2026-08-04): split — a strict authoring `ViewItemSchema` and a reopened
wire member in the union. |
| objectstack-ai#5074 | `view-strictness-batch18.test.ts:403` | "[RESOLVED with the
ruled split] ListViewSchema.sort CLOSED — …" | The split's scope
addendum: the wire door strips the console's decoration keys before
validating, so `sort[]` closed again with no declared `id`. |
| objectstack-ai#7025, objectstack-ai#7741 | `view-union-diagnostics.test.ts:246` | "the acceptance
face of ViewMetadataSchema — frozen by the diagnostics work; every move
since is deliberate and pinned" | objectstack-ai#7025's sweep rule: the diagnostic
face improves, the acceptance face does not move; objectstack-ai#7741's ruled binding
requirement is the first pinned move since. |
| objectstack-ai#9463 | `view.test.ts:342` | "viewMode — the granularities the gantt
renderer honours, measured" | Declare `viewMode` with exactly the
granularities objectui's `GanttView` honours, measured, not invented
(the spec half of objectui#5074's ruling). |
| objectstack-ai#17053 | `view.test.ts:441` | "the legacy string `sort` clause is
retired — one spelling, the array" | objectui's ruling (director batch
objectstack-ai#77, option B): one spelling, the array; the spec stops producing the
string. |
| objectstack-ai#13704 | `view.test.ts:873` | "wizard tightening — sections are the
steps, the inert step keys are refused, no key is added" | The ruled
shape of objectstack-ai#13622 (2026-08-31): sections are the steps, the wizard-inert
step keys are refused at parse, zero new keys. |
| `objectui#6237` | `view.test.ts:949` | "… stay accepted on
tabbed/simple (the ruled split confines it to wizard steps)" |
Maintainer ruling 2026-08-30 (director batch objectstack-ai#3): `FormSectionConfig` is
split, so tabbed sections take a predicate and wizard steps carry none.
|
| `objectui#2231` | `view.test.ts:2876` | "ListColumnSchema summary
object form and prefix — spec-owned, no longer an objectui-local
extension" | The derive-by-reference unification: `677b591` moved
`prefix` and the `{ type, field }` `summary` form into the spec, closing
objectui's local `.extend()`. |
| objectstack-ai#3896 (see above) | `view.test.ts:3144` | "FormViewSchema — retired
defaultSort (audit close-out: nothing read it)" | The landed tombstone:
`form.defaultSort` was removed because nothing read it. |
| `objectui#5435` | `view.test.ts:3386` | "… defaults asymmetry, copied
from what the renderer reads" | Ruling A (2026-08-22): the spec adopts
`group` / `hideFields` / `rowColor`, with the defaults copied from
`ListView`'s reads. |
| objectstack-ai#3896 (see above) | `view.test.ts:3826` | "ListViewSchema — retired
responsive/performance (audit close-out: no renderer read them)" | The
landed tombstones: no renderer or runtime read either key. |
| objectstack-ai#7176 | `view.test.ts:3847` | "ListViewSchema — retired
striped/bordered/virtualScroll (every reader only passed them through)"
| Maintainer ruling 2026-08-10: retire under ADR-0049, since every
measured reader copied the keys forward and none applied them. |
| objectstack-ai#5832 | `view.test.ts:4099` (expect message) | "`HttpMethodType` was
renamed to `HttpMethodSubset`" | Maintainer ruling 2026-08-06: rename
the 5-value subset; the 7-value `HttpMethod` keeps its name and its wire
contract. |
| objectstack-ai#16577, objectstack-ai#13817 | `view.test.ts:4708` | "… the `type: 'calendar'` axis
is NOT gated by the `allowedVisualizations` check (ruled: a completeness
warning)" | Ruling B (director seat, 2026-09-11): the objectstack-ai#13817 guard keeps
gating `allowedVisualizations` only; the `type: 'calendar'` route is
carried at warning by `checkViewCompleteness`. |
| objectstack-ai#19228 | `view.test.ts:4793` | "view row bound — `pagination.pageSize`
is the one bound; no per-kind `limit` on the view configs" | Maintainer
ruling D (2026-09-23): one row bound per view, `pagination.pageSize`;
the per-kind `limit` was removed before it shipped. |
| objectstack-ai#5055 | `widget-i18n-retirement.test.ts:70` | "ui/ widget + i18n
family retirement — doorless vocabularies removed, not tightened" |
Maintainer ruling A (2026-08-06): ADR-0049 enforce-or-remove retires the
unreachable widget and locale vocabularies; closing them would only
dress a dead slot as a checked one. |
| `objectui#3289` | `widget-i18n-retirement.test.ts:196` | "the
surviving `error` slot is exactly the one objectui renamed its own slot
onto, with no alias" | objectui followed the spec: its widget
`errorMessage` slot became the spec's `error`, with no alias, and the
form renderer produces it. |

**Subject back in words** (20 literals): "(binding pair, objectstack-ai#7741)" becomes
"(the object + viewKind binding pair)"; "union error behaviour (objectstack-ai#5014)"
becomes "union error behaviour (where a branch prescription gets
buried)"; the five `objectstack-ai#7496` prefixes become "the ruled redirect `url`
shape —" (twice) and "ruled bullet 1 / 2 / 3 —", the file's own name for
the ruling's three bullets; "the pre-objectstack-ai#7510 ranking" becomes "the pre-fix
ranking"; "the objectstack-ai#4001 wrap prescription" becomes "the `defineView` wrap
prescription"; the acceptance-face describe at
`view-union-branch-focus.test.ts:261` (above); "the objectstack-ai#6926 fold" becomes
"the producer-side `groups` fold"; "(objectstack-ai#7025 membership)" becomes "and the
union corpus pins it accepted"; "(objectstack-ai#8321/objectstack-ai#12174)" becomes "(a negative or
fractional scale)", what that test probes; "the exact declaration objectstack-ai#9340
exists to make legal" and "the gap objectstack-ai#9340 closes" name "this block";
"(acceptance criterion, objectstack#11195)" becomes "(the acceptance
criterion for adopting the three keys)"; "(objectstack-ai#7176 rides …)" becomes "(the
retirement rides …)"; "the axis objectstack-ai#13817 does not gate" becomes "the axis
the `allowedVisualizations` check does not gate"; "zero holders after
objectstack-ai#5055" becomes "after the widget + i18n retirement"; "objectstack-ai#5055 — the one
surviving shape" becomes "the widget retirement — the one surviving
shape".

**Dropped where already stated** (55 literals, 57 ids). A number goes
only where the title already says its decision. Examples: the four
`[objectstack-ai#19920]` prefixes ("… typed by its arm, not unknown", "… a parsed view
body, not unknown") and `[objectstack-ai#19871]`; the six `[objectstack-ai#20051]` describes on the
`options` bag and the one in
`view-union-retirement-prescription.test.ts`; the eight `objectstack-ai#20186`
describes ("a column-less list PATCH is judged by the list member",
"each member judges ONE viewKind", …); the three `[objectstack-ai#6391]` describes,
three `[objectstack-ai#7510]` titles and the `[objectstack-ai#21180]` table label ("the retired
`publicPicker` key itself"); "(objectstack-ai#3095)", "(objectstack-ai#5074)" after "`.strip()`
round-tripping is untouched", "(objectstack-ai#9933)" after "runtime-only overlay
key", and the `view.test.ts` tails `(objectstack-ai#15469)`, `(objectstack-ai#6926)`, `(objectstack-ai#12174)`,
`(objectstack-ai#19088)`, `(objectstack-ai#7084)`, `(objectstack-ai#9340 — …)`, `(objectstack-ai#17499)`, `(objectstack-ai#18791)`,
`(framework#1894 / objectstack-ai#2998)`, `(objectstack-ai#5073 — …)` x2, `(objectstack-ai#8010)`, `[objectstack-ai#4688]`,
`[objectstack-ai#4691]`, `(objectstack-ai#6416 / objectstack-ai#6619)`, `(objectstack-ai#17063)`, `(objectstack-ai#16885)`, `(objectstack-ai#13817)` and
`(objectstack-ai#16577)`. The batch label `批 18` stays, in stage 20's "批 19, unknown
keys refused" form on the file's first describe and bare on the other
four. `W2` stays: the file's own header defines W1 and W2. The commit
`ce70876e` stays in "(measured on origin/main ce70876)": a commit, not
a tracker id.

**No file is renamed.**

## Readers

- **Test-name filters:** none. No tracked script, workflow or package
config passes `-t` / `--testNamePattern` (the 3 hits are `docker build
-t`, `type -t` and `lsof -t`).
- **Snapshots:** none. No `__snapshots__` directory is tracked under
`packages/spec`, and none of the 16 files calls a snapshot matcher.
- **Projects:** none of the 16 files is in the `repo` project
(`packages/spec/vitest.repo-tests.json`); all run in `local`.
- **By substring:** every old literal, its id-bearing fragment and a
window around each id (299 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 16 hits are windows that share wording with code comments
and one CHANGELOG line: 15 comments in the migration registry, its
semantic entries and `view-list-tabs-retirement.test.ts` read "(ruling B
on objectstack-ai#17152)", and `packages/spec/CHANGELOG.md:30342` reads "runtime-only
overlay key (objectstack-ai#9933)".
- **Cross-references by id:** two places name the `columnState` section
of `view-metadata-schema.test.ts` as "§objectstack-ai#9933": a code comment at
`packages/spec/scripts/strictness-ledger.test.ts:380` and the
`view.zod.ts` row of
`docs/audits/2026-07-unknown-key-strictness-ledger.md`. Neither matches
a string; both point a reader at the section, which still carries
"columnState — runtime-only overlay key" in its title and its banner
comment. A code comment and an audit record are not this card's share,
so neither is edited.

## 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 three lines:
`view-union-branch-focus.test.ts:139`, `view.test.ts:4099` and
`widget-i18n-retirement.test.ts:135`.

- **Result:** 16 of 16 files SAME on all three legs, with the per-file
counts predicted in writing before the run.
- **Totals:** 100 changed string leaves in 100 literals: 97 titles and 3
declared. The diff's `+` and `-` lines are exactly the 100 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; the
declared table label given a new id VIOLATION; a template-literal title
given a new id VIOLATION.
- **Templates and tables:** one `.each` title changes,
`view-overlay-viewkind-arm.test.ts:102`, a `%s` template whose
placeholder and rows are untouched. The table label at
`view-union-branch-focus.test.ts:139` feeds two `for … of` template
titles, which print it whole. The template-literal title at `:173`
changes only its text before `${label}`.

**Test counts:** the 16 files were run at the base, in a separate base
worktree, and at the head, with `--project local --project repo`. Both
sides read 862 tests in 16 files, all passed, with the same count and
status sequence per file in 16 of 16. 574 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
16 touched files are in it, and no `*.test.ts` at all. The controls
`src/ui/view.zod.ts`, `src/ui/widget.zod.ts` and `dist/index.mjs` are in
it.
- In the built `dist/`, a new phrase 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 `75022207b3`)

- `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 16 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 22, 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 16 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 16 configured, 0 ignored. No file sets `parserOptions.project`
or `projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 200 changed lines (+100
/ -100).
- A control-byte scan over the 16 changed files finds none.

## `main` since the base

Re-fetched just before this PR opened, `origin/main` was four commits
past the base (`1f0469655f`: objectstack-ai#21939, objectstack-ai#21937, objectstack-ai#21943, objectstack-ai#21928). They touch
32 files, none of the 16; two are under `packages/spec` (a step-18
semantic migration entry and the migration registry, neither a test
file). So `main` was not merged. The census on that tree still reads 571
/ 604 in test files and 0 elsewhere. `git merge-tree` onto `1f0469655f`
is clean, and none of the 8 open PRs touches any of the 16 files.

## Acceptance notes

- **The `{{record.FIELD}}` title.**
`view-submit-redirect-url.test.ts:187` keeps its literal placeholder
after "ruled bullet 2 —"; this body spells it with `FIELD` because the
platform strips angle-bracket fragments from PR text.
- **Same-id test titles in this card's later stages** go with those
stages: 5 lines in `packages/spec/src`,
`api/api-error-code-type.test.ts:71` ("[objectstack-ai#19920] …"),
`stack.test.ts:1510` ("(objectstack-ai#17063)"), `system/stack-server.test.ts:93` and
`system/translation.test.ts:672` / `:767` ("(objectstack-ai#4001)").
- **Same-id test titles in other packages** stay: 46 lines in 11
packages (`metadata-protocol` 11, `objectql` 8, `spec/scripts` 8, `lint`
6, `rest` 4, `plugin-auth` 3, `cli` 2, and one each in
`plugin-security`, `plugin-sharing`, `qa/dogfood` and
`service-automation`), each package's share under the objectstack-ai#20513 lane
children. The three `plugin-auth` titles cite objectstack's objectstack-ai#5233, a
different record from `objectui#5233`.
- **Code comments with live ids** remain in these files and their
sources, among them the "§objectstack-ai#9933" cross-references above, the `[objectstack-ai#7741]` /
`[objectstack-ai#21180]` corpus notes in `view-union-branch-focus.test.ts` and
`view-union-diagnostics.test.ts`, and the `objectstack-ai#5055` banners in
`widget-i18n-retirement.test.ts`. 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants