Skip to content

Release v0.3.3 - #146

Merged
hotlong merged 3 commits into
mainfrom
copilot/release-new-version-bc852898-239f-4d9b-aab2-df6a211ff2f3
Jan 25, 2026
Merged

hotlong merged 3 commits into
mainfrom
copilot/release-new-version-bc852898-239f-4d9b-aab2-df6a211ff2f3

Conversation

Copilot AI commented Jan 25, 2026 •

Copy link
Copy Markdown
Contributor

Patch release bumping all packages from 0.3.2 to 0.3.3 using Changesets.

Changes in This Release

  • Enhanced GitHub workflows (CI, release, PR automation)
  • Added protocol-specific prompt templates (data, UI, system, API, AI)
  • Updated changeset configuration for unified versioning
  • Added cursor rules and automation documentation

Technical Details

  • All 8 packages use fixed versioning (released together)
  • CHANGELOGs auto-generated via changeset version
  • Git tag v0.3.3 created for this release
  • Packages ready for npm publish via GitHub Actions workflow
Original prompt

发一个版本


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

@vercel

vercel Bot commented Jan 25, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
spec Ready Ready Preview, Comment Jan 25, 2026 5:48am

Request Review

Copilot AI and others added 2 commits January 25, 2026 05:44
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI changed the title [WIP] Create a new release version Release v0.3.3 Jan 25, 2026
Copilot AI requested a review from hotlong January 25, 2026 05:52
@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file size/m labels Jan 25, 2026
@hotlong
hotlong marked this pull request as ready for review January 25, 2026 05:56
Copilot AI review requested due to automatic review settings January 25, 2026 05:56
@hotlong
hotlong merged commit 460be5b into main Jan 25, 2026
14 of 15 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Release PR for v0.3.3, updating package versions/changelogs and publishing collateral (root changelog + release notes), with added Tenant reference artifacts.

Changes:

  • Bumped all workspace package versions from 0.3.2 → 0.3.3 (and examples accordingly).
  • Updated package-level CHANGELOGs and root CHANGELOG.md/RELEASE_NOTES.md for the v0.3.3 release.
  • Added Tenant schema artifacts: JSON Schema + docs reference page.

Reviewed changes

Copilot reviewed 38 out of 38 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/types/package.json Bump @objectstack/types to 0.3.3
packages/types/CHANGELOG.md Add 0.3.3 changelog entry
packages/spec/package.json Bump @objectstack/spec to 0.3.3 (and export formatting)
packages/spec/json-schema/hub/Tenant.json Add Tenant JSON Schema artifact
packages/spec/CHANGELOG.md Add 0.3.3 changelog entry
packages/runtime/package.json Bump @objectstack/runtime to 0.3.3
packages/runtime/CHANGELOG.md Add 0.3.3 changelog entry (deps bumped)
packages/plugin-msw/package.json Bump plugin-msw to 0.3.3 + peerDep runtime ^0.3.3
packages/plugin-msw/CHANGELOG.md Add 0.3.3 changelog entry (deps bumped)
packages/plugin-hono-server/package.json Bump plugin-hono-server to 0.3.3 + peerDep runtime ^0.3.3
packages/plugin-hono-server/CHANGELOG.md Add 0.3.3 changelog entry (deps bumped)
packages/objectql/package.json Bump @objectstack/objectql to 0.3.3
packages/objectql/CHANGELOG.md Add 0.3.3 changelog entry (deps bumped)
packages/driver-memory/package.json Bump @objectstack/driver-memory to 0.3.3
packages/driver-memory/CHANGELOG.md Add 0.3.3 changelog entry (deps bumped)
packages/client/package.json Bump @objectstack/client to 0.3.3
packages/client/CHANGELOG.md Add 0.3.3 changelog entry (deps bumped)
examples/todo/package.json Bump example-todo to 1.0.7
examples/todo/CHANGELOG.md Add 1.0.7 changelog entry (deps bumped)
examples/plugin-bi/package.json Bump plugin-bi example to 1.0.7
examples/plugin-bi/CHANGELOG.md Add 1.0.7 changelog entry (deps bumped)
examples/msw-demo/package.json Bump msw-demo example to 0.1.2
examples/msw-demo/CHANGELOG.md Add 0.1.2 changelog entry (deps bumped)
examples/host/package.json Bump host example to 0.1.7
examples/host/CHANGELOG.md Add 0.1.7 changelog entry (deps bumped)
examples/crm/package.json Bump crm example to 1.0.7
examples/crm/CHANGELOG.md Add 1.0.7 changelog entry (deps bumped)
examples/ai-support/package.json Bump ai-support example to 1.0.5
examples/ai-support/CHANGELOG.md Add 1.0.5 changelog entry (deps bumped)
examples/ai-sales/package.json Bump ai-sales example to 1.0.5
examples/ai-sales/CHANGELOG.md Add 1.0.5 changelog entry (deps bumped)
examples/ai-codegen/package.json Bump ai-codegen example to 1.0.5
examples/ai-codegen/CHANGELOG.md Add 1.0.5 changelog entry (deps bumped)
examples/ai-analyst/package.json Bump ai-analyst example to 1.0.5
examples/ai-analyst/CHANGELOG.md Add 1.0.5 changelog entry (deps bumped)
content/docs/references/hub/tenant/Tenant.mdx Add Tenant docs reference page
RELEASE_NOTES.md Add v0.3.3 release notes section
CHANGELOG.md Add 0.3.3 entry + update compare links

Comment on lines +8 to +9
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |

Copilot AI Jan 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The markdown table uses a double leading pipe (|| ...) on the header and alignment rows, which is inconsistent with other reference pages (e.g., content/docs/references/hub/space/HubSpace.mdx) and likely breaks table rendering. Use single leading pipes (| ...) like the other docs.

Copilot uses AI. Check for mistakes.
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…d an 'end' node (objectstack-ai#18688)

Part of objectstack-ai#15646

Clause-②: yes

The flow accept set shrinks for five node types inside region bodies —
shapes the runtime never honoured. Ruling D clause 4 states it verbatim.

⚠️ **The runtime half is NOT in this PR.** A region-contained node that
*durably suspends* must fail the run with a named error — only the run
can know that — and ruling D assigns it to a separate `domain:services`
card. ⇒ this PR lands with `Part of`, ⛔ not `Fixes`; **objectstack-ai#15646 stays open
until the runtime half lands.**

---

## ⚠️ SEAT BANNER — ruling **D** superseded the route this body argues
for

Everything below the horizontal rule was written when the card was ruled
**C**, and it argues for **route A** ("Recommendation: A, as
implemented"). ⛔ **That is no longer what this PR does.** It is kept
unedited as the record of how the decision was reached — ⛔ deleting it
would erase the evidence the later ruling was made on.

**What this PR does NOW**, per ruling D (batch objectstack-ai#153 item 1, comment
`5724940095`, maintainer 「其他同意」):

- Inside `loop` / `parallel` branch / `try_catch` (try **and** catch)
bodies at any depth, `FlowSchema.superRefine` refuses **five** node
types: `screen`, `wait`, `approval`, `approval_revise`, and `end`.
- ⛔ **`map` and `subflow` are NOT refused by type.** They pause exactly
when the child flow their `config.flowName` names pauses — a *different
metadata record*, not in hand at parse. Refusing them by type would also
refuse `loop { map(synchronous child) }`, which runs correctly today.
- `packages/spec` keeps its published identifier
`FLOW_PAUSE_CAPABLE_NODE_TYPES`; only its **contents** narrow.
- **`packages/services` is untouched by this round** — measured, zero
paths. The 5-tests-in-3-files cost the section below describes **does
not occur**: the whole package runs **138 files / 1652 tests, all
passing**, with a false-green control proving the test read the rebuilt
artifact and not a stale `dist`.

⚠️ **Corrected by the seat, and it is a DECLARED DEVIATION from ruling
D's letter — ⛔ not a clean pass.** 「zero paths」 is true of **this
round's commits** and ⛔ NOT of the PR's cumulative diff:
`packages/services/service-automation/src/end-node-refused-outcome.test.ts`
(+57/−35) is in the diff, from the earlier round's commit `87973cab8d1`.
Ruling D clause 1 says 「`packages/services` untouched; **the 5 tests**
and objectstack-ai#15616's suite stand」 — and objectstack-ai#15788's region-`end` case **was one of
those 5**. ⇒ the ruling's premise 「B breaks nothing」 was **false for
that one case**: clause 1 itself orders `end` refused at parse, and
`registerFlow` parses, so the old run-time assertion is unreachable by
construction. What was done: the fixture is **byte-identical**, case
count **12 → 12**, and the assertion is **strengthened** (region path,
message text, and that nothing registered) so it fails again the day the
shape becomes declarable. ⛔ No test deleted, skipped or quarantined; ⛔
no engine source moved; objectstack-ai#15616's suite and the other three files are
untouched and green. The at-tier review measured all of this and ruled
it non-blocking — but it is a deviation and it is stated here rather
than buried.

**CI on `6de9d662f6df`: 32 success, 3 skipped, 0 failure, 0 pending.**

### ⛔ Two corrections the `domain:spec` seat owes on its own record

1. ⭐ **The seat ruled "keep the published name" on a premise that is
FALSE.** It told the round that renaming `FLOW_PAUSE_CAPABLE_NODE_TYPES`
removes a *published* export and therefore forces a major. Measured
since: main's `packages/spec/api-surface/automation.json` greps **0**
for that name (lit controls `FLOW_BUILTIN_NODE_TYPES` and
`FLOW_STRUCTURAL_NODE_TYPES` = 1 each; dark control = 0), and the branch
greps 1. ⇒ **the constant is introduced by this PR and is on no
consumer's import path**; the gate's 「1 breaking (removed)」 was computed
against the branch's own earlier snapshot. The *decision* stands and
costs nothing — a second name would be cost without benefit — but ⛔ the
record must not carry 「a removed published export」 as a fact about
consumers. The round measured this and told the seat; the seat
re-measured and confirms it.
2. ⛔ **THIS CORRECTION WAS ITSELF WRONG, and the seat withdraws it.** It
claimed `--pair 18688` re-measured 「exit 0」 at this head. **That reading
came from a STALE INSTRUMENT.** The shared checkout's
`check-clause2-carriers.mjs` is blob `ccd5ad7c9a00` and contains **0**
occurrences of rule **C8**; `origin/main`'s and this head's is blob
`3a270ef2eb5f` and contains **18** (lit control `C1`: 50 vs 51, so the
reader works). C8 landed on `main` at 01:41Z via objectstack-ai#18859 and the shared
checkout never had it. ⇒ every `--pair` reading this seat took today was
taken with a script that cannot see C8. Re-taken with `origin/main`'s
script: **objectstack-ai#18688 exit 4 on C8** — this seat held **two live `Claim:`
comments** on objectstack-ai#15646 (`5722016855`, `5728277407`), which the protocol
forbids. Repaired as C8 prescribes: `Release:` (`5729634742`) then ONE
fresh `Claim:` (`5729639847`). **`--pair 18688` now exits 0** —
`claim.selected` 1, `claim.rejected` 2. The at-tier review caught this;
the seat re-measured and confirms it.

---

Route **C**, as ruled. Director seat, summon objectstack-ai#24, batch objectstack-ai#145 item 5 —
objectstack-ai#15646 (comment)
(maintainer 「同意,其他也同意」), with the batch objectstack-ai#146 scope addition —
objectstack-ai#15646 (comment)
(maintainer 「146 同意」), which attached objectstack-ai#3267's 禁 ruling and absorbed
objectstack-ai#18112 into this card. One PR, one changeset, two refusals in one rule
family.

## 🛑 Read this first — this PR is NOT ready to land, and the reason is a
measured decision, not a bug

`packages/spec` is green end to end. **5 tests in 3
`packages/services/service-automation` files now fail**, and every one
of them fails for the same reason: the fixture can no longer be
REGISTERED, because `AutomationEngine.registerFlow` parses through
`FlowSchema.parse` (`engine.ts:3941`) and this rule refuses the shape.

⚠️ **Corrected by the `domain:spec` seat after a classification round —
the table below replaces one that named 5 tests in 3 files.** That
earlier count was taken by running **three named files**; CI runs `pnpm
--filter @objectstack/service-automation test`, the whole package, and a
named-file subset cannot see this class of breakage. `os-dev.md:56`
reserves this body to the PR-open write, so the round named the wording
and the seat writes it.

| File | Failing | Card | What it pins |
| --- | --- | --- | --- |
| `src/builtin/contained-failure-rollup.test.ts` | **7** | objectstack-ai#16314 | the
contained-failure rollup fold over `loop { subflow }` — ⚠️ **absent from
the earlier table entirely**; it predates this branch's base (`git
merge-base --is-ancestor` exit 0), so this is a measurement gap, ⛔ not
drift |
| `src/builtin/map-in-loop-iteration-state.test.ts` | 3 | objectstack-ai#15616 | `loop
{ body: [ map, probe ] }` over a **non-pausing** child: 5 iterations x 2
items ⇒ 10 child runs, `failed = 0` either way, a fresh result set per
iteration |
| `src/builtin/contained-failure-visibility.test.ts` | 1 | objectstack-ai#14456 | a
parent run's row identity does not leak into a `subflow` child; the
region shape is the **vehicle**, not the subject |
| `src/end-node-refused-outcome.test.ts` | 0 (was 1) | objectstack-ai#15788 | ⭐
**fixed on this branch** — see below |

**Measured with the package suite:** at `e10b395cee`, **12 failed / 1627
passed (1639)** across **4 files**. After the fix below, at
`87973cab8d1`: **11 failed / 1628 passed**.

⭐ **One of the twelve was never blocked on the open question, and it is
repaired here.** objectstack-ai#15788's region-`end` case sits in **both** candidate
populations — this body defines route B as the unconditionally pausing
types **plus `end`** — so no answer to the question below moves it. It
is re-homed to the registration refusal: the fixture is unchanged byte
for byte, and the case now asserts the ZodError's located path, its
message and prescription, and that **nothing registered**. ⛔ Not a
deletion — it fails again the day the shape becomes declarable.

**The 11 are mutually exclusive with route A, and that is measured
rather than argued.** Ablating `FLOW_PAUSE_CAPABLE_NODE_TYPES` to route
B's definition turns **all 11 green with nothing else moving**; route A
on the same four files is 11 red. ⚠️ Method note that is load-bearing:
`service-automation` resolves `@objectstack/spec` through **`dist`**, so
the ablation was rebuilt and verified present in 18 built artifacts
before anything was read — an unrebuilt ablation would have gone green
and proved nothing. Restored afterwards, verified absent from all 216
artifacts, whole-tree porcelain empty.

⭐ **The 11 are NOT one cost.** 3 of them (objectstack-ai#15616) are free: under route
A the shape becomes undeclarable, so the defect is unreachable and the
regression suite converts to a refusal pin — mechanically, the same
conversion performed above for objectstack-ai#15788; that file's second describe (a
TOP-LEVEL pausing map) is untouched and green, so the durable-pause half
keeps its coverage. The other 8 (objectstack-ai#16314, objectstack-ai#14456) are a genuine re-home
onto a top-level delegating node, and `loop { subflow }` over five rows
with one failing is the shape objectstack-ai#15617's ruling **named**, so any re-home
must record that the measurement no longer runs on it.

⛔ **Not repaired here.** The dispatch fences `packages/services` ("the
engine's runtime refusal stays exactly as it is") — ⚠️ and note
precisely what that fence claims: it is true of the **diff**, which
touches no `packages/services` file. Read as a claim about **effect** it
is false, because the parse refusal changes what those suites can
register. The changeset carries the same correction, and two of these
three are other cards' regression suites: deleting or re-homing objectstack-ai#15616's
and objectstack-ai#15788's coverage is a decision, not a fixture edit. **Two of them
are also evidence about the rule itself**, which is the open question
below.

### The open question: does the narrowing take a shape that WORKS with
it?

The ruling's population is "a node that **can durably pause** (`map` /
`subflow` **with a pausing child**, approval-class nodes)". Measured:
`map` and `subflow` pause **exactly when the child flow they NAME
pauses** — a different metadata record — so "with a pausing child" is
**not decidable at parse**. Only two spellings are:

- **A — judge the node TYPE** (what this PR implements). Closes this
card's own reproduction, covers all three region kinds, and is the only
reading under which C is the *complete* fix the ruling's own reasoning
requires. **Cost, measured:** it also refuses `loop { map(synchronous
child) }` — a shape that runs correctly today and was deliberately fixed
12 days ago by objectstack-ai#15616 / PR objectstack-ai#15648, whose regression suite is 3 of the 5
failures above.
- **B — judge only the UNCONDITIONALLY pausing types** (`screen` /
`wait` / `approval` / `approval_revise`) plus `end`. Refuses nothing
that works today, and the 3 `map` failures disappear. **Cost:** this
card's own reproduction — `loop { try_catch { map(pausing child) } }` —
stays declarable and stays silently green, so the card is not closed.

There is no third reading available to a parse. **Recommendation: A, as
implemented** — objectstack-ai#3267 is ruled 禁 ("structured regions do not support
durable pause"), and a shape whose legality lives in a record the author
is not editing, revocable by editing that record, is not a contract.
Under A the five tests are re-homed (a top-level `map`, a top-level
`end`) or retired with a statement, in this PR or a follow-up, once the
seat says the coverage may move.

## Step Zero — the ruling's precondition, answered before any code was
written

> **First step, before writing**: prove the nesting is statically
decidable at parse/validate time.

**Answer: YES for the nesting and for the node vocabulary this rule
judges, with two boundaries that are declared rather than discovered.**
What was measured, on this branch's base `7f7b8557df`:

1. **The nesting is decidable, and a refusing layer already exists.**
`collectFlowGraphs` (`packages/spec/src/automation/control-flow.zod.ts`)
yields the top-level graph plus every region body, depth first, with a
`scope` label and a `path` that anchors a Zod issue where the author
wrote the node. `FlowSchema`'s `superRefine` already walks exactly that
and refuses on it — the objectstack-ai#16134 one-node-id-space rule. The PM seat's
clue held: there is no *refusing* layer for this shape, but the walk and
the refusal machinery are both live and in the same file.
2. **The pausing vocabulary is statically declared for the built-in
set.** Derived by reading the shipped `defineActionDescriptor` literals,
not by recall: `supportsPause: true` appears on `screen` / `wait` /
`subflow` / `map` (`packages/services/service-automation/src/builtin/`)
and `approval` / `approval_revise`
(`packages/plugins/plugin-approvals/src/`) — six, the same six the
ADR-0044 `resumeAuthority` default-flip migration entry names in its own
prose. They are published here as `FLOW_PAUSE_CAPABLE_NODE_TYPES`.
3. **`end` is fully static** — `FLOW_STRUCTURAL_NODE_TYPES`, a node type
the engine handles with no executor at all.

**What is NOT decidable, and what this rule does about it.** Whether a
given node *will* pause is not decidable at parse, in two different
ways, and both are stated in the docblock, in the changeset and in the
ADR-0087 entry:

- **`map` / `subflow` pause exactly when the child flow they NAME
pauses** (`map.config.flowName`, an opaque reference to another metadata
record). So the rule judges the node **TYPE**, not the run. That is
wider than the runs that actually broke — a region-nested `map` over a
synchronous child parsed green before and is refused now — and it is
deliberate: the old shape's legality lived in a record the author is not
editing and could be revoked by editing that record. "Legal until
somebody adds a `wait` to the child flow" is not a contract.
- **A plugin-registered pausing type is invisible to a parse.** ADR-0018
left the node-type namespace open (`FlowNodeSchema.type` is a validated
`string`), and a parse has no registry. Pinned as a boundary test so it
moves deliberately.
- **`MAX_REGION_DEPTH` (32).** The walk stops there. ⚠️ Unlike objectstack-ai#16134's
duplicate-id rule, there is **no second spec refusal behind the
ceiling** for this rule — `analyzeRegion` says nothing about pausing
nodes — so past depth 32 the engine's run-time refusal is the only one.
Measured and pinned at nesting 32 (refused) / 33 (not judged), and
stated in the changeset rather than left for an author to find.

## What changed

`FlowSchema.superRefine` gains one walk over `collectFlowGraphs`,
skipping the flow's own graph, that raises a `custom` issue anchored at
`[...regionPath, 'nodes', i, 'type']` for:

- **a pause-capable node** in a region body — the message names the
node, the region scope (`loop 'sweep' body → try_catch 'guard' try`),
why a region body cannot host it, and the fix;
- **an `end` node** in a region body, whatever its `outcome` — an `end`
there was a no-op, and a refusing one was converted into a region error
at the same boundary (objectstack-ai#15788). The ruled prescription is the message: a
region body cannot end the run; put the `end` on the top-level graph.

`FLOW_PAUSE_CAPABLE_NODE_TYPES` is the new export (`api-surface` /
`export-origins` regenerated). The two approval entries are the declared
constants `APPROVAL_NODE_TYPE` / `APPROVAL_REVISE_NODE_TYPE`, so a
rename cannot desynchronise them.

⛔ `packages/services` is untouched — this is authoring-time enforcement
only. ⛔ No engine rollback seam (route A, no card filed, per the
ruling). ⛔ No runtime detection in `map` (route B, refused). ⛔ objectstack-ai#15617's
`failed` fold is not addressed.

## Tests

New file
`packages/spec/src/automation/flow-region-pause-and-end.test.ts` — every
case fails without the rule:

- both refusals × all three region kinds: `loop` body, `try_catch` try
**and** catch, `parallel` branch. A rule covering `loop` only is route B
wearing C's clothes; the `try_catch` catch arm and the `parallel` branch
arm are the two route B could never see, and each has its own case.
- all six pause-capable types, table-driven off the exported constant.
- the card's own reproduction, `loop { try_catch { map } }`, refused
with the chained region path.
- **negative tests, the over-reach guard**: every pause-capable type and
an `end` still parse on the **top-level graph**; every non-pausing type
still parses inside a region; a node merely *named* `end` or `wait` in a
region still parses (the rule judges `type`, not `id`).
- both declared boundaries pinned: the plugin-contributed pausing type,
and the depth-32/33 seam.
- `defineFlow` and `formatZodError` renderings.

Two existing cases pinned the behaviour this rule replaces and were
**replaced rather than re-spelled**, each saying so in its own comment:
`end-node-outcome.test.ts`'s region-nested `end` (its subject — an
`end`-in-region whose *config* is judged one door later — no longer
exists) and `flow.test.ts`'s BPMN `waitEventConfig` region case (now
asserts the earlier refusal *and* keeps the region-contract half it
actually exists to measure). The `requireTypeScopedConfig` docblock that
asserted a nested block-less `wait` parses green was corrected in the
same edit.

## Verification

Measured on `e10b395cee`. Heavy runs go through
`scripts/pm/os-verify-lock.sh`; every exit code below is read from the
wrapper's own `VERDICT command-exit` line or captured into a variable
**before** any pipe — never `$?` after one.

| Command | Verdict |
| --- | --- |
| `pnpm --filter @objectstack/spec build` | `command-exit 0` |
| `pnpm --filter @objectstack/spec test` (whole package) | `command-exit
0` — **486 files, 13895 tests, 0 failed** |
| `pnpm --filter @objectstack/spec typecheck` (`tsc --noEmit` +
`check:scripts-typecheck` + `check:test-typecheck`) | `command-exit 0` |
| `pnpm --filter @objectstack/spec check:generated` | `command-exit 0` —
all 15 artifacts up to date |
| `pnpm lint` (whole repo, `eslint . --no-inline-config`) | `exit 0` —
run in full, so nothing here is a narrowing |
| `dispatch-gates.mjs --ran` reconciliation | `exit 0` — **85 derived,
81 run, 4 NOT-MEASURED, 0 UNRUN** |
| `@objectstack/service-automation` — the 3 files whose fixtures feed
this rule | `exit 1` — **5 failed / 24 passed**, see the section at the
top |

**Reverse verification (one-shot, restored).** The rule's own early-exit
was mutated (`graph.path.length === 0` → `>= 0`), and the mutation was
proved on disk before anything was read from the run — anchor grep 1 →
0, marker grep 0 → 1, blob `044bbbba` → `56a1c350`:

- **ablated** — `flow-region-pause-and-end.test.ts`: **20 failed / 7
passed**. The 20 are exactly the refusal assertions; the 7 that survive
are the over-reach guards and the two boundary pins, which must stay
green with the rule absent. That split is itself the reading: a rule
that also broke the negative cases would be refusing too much.
- **restored** — `git checkout HEAD --` the file, blob back to
`044bbbba`, `git diff HEAD` clean, `git status` empty, same file **27/27
passed**.

No `dist` preflight applies: the test imports `./flow.zod` by relative
source path, so the subject never resolves through `packages/spec/dist`.
A restore `trap` was armed for the whole window.

**The four NOT-MEASURED gates** are `check:doc-formula-expressions`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure` and
`check:type-check-debt` — each exited **3, PREREQUISITE NOT MET**,
printing in its own words that nothing was measured. All four read built
output across packages this diff does not touch and need a repo-wide
build; CI's `Build Core` and `Lint & Repo Gates` are where they run. ⛔
Not green, not red — unrun.

**Not measured, stated:** CI convergence on this PR (the report is filed
at the end of local verification); the branch has not been merged
forward since `7f7b8557df`, so `main`'s newer commits are tested by CI
and the queue rather than here.

**Review-gate reading, not an action.**
`scripts/pm/check-clause2-carriers.mjs --pair 18688` exits **4** on two
rows, both belonging to the claiming seat and ⛔ neither touched here:
**C1** — card objectstack-ai#15646 carries `needs:contract-review` while this PR does
not (the gate is a dual carrier); **C2** — no comment on the card's
thread is a machine-legible claim comment (none has a first line
beginning `Claim:` carrying the `Clause-②:` line), so the declaration
limb has nothing to read. The declaration itself is at the top of this
body and in the changeset.

## Acceptance notes

Observations from this card's reading, recorded here and **not** filed —
none is a reproducible defect, a contract violation, or an authoring
trap:

- The ruling's own parenthetical, "`map` / `subflow` **with a pausing
child**", describes the defect population rather than a decidable rule
population, and its "a shape the runtime never honoured" is exact for
`end`, `screen`, `wait` and the approval pair but not for a `map` over a
synchronous child, which runs today. The PR takes the capability reading
— the only one that makes C the complete fix the ruling's own reasoning
requires — and the changeset states the cost in the author's own terms.
Noted so a reviewer reads the widening deliberately rather than
discovering it.
- The card body and the batch objectstack-ai#145 ruling both say objectstack-ai#15617 is open; it is
**closed / completed**. Nothing here depends on it.
- `engine.ts:9937` in the ruling reads `engine.ts:9970` on this tree —
line numbers are clues, and this one was re-read rather than trusted.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ai#18012) (objectstack-ai#19066)

Fixes objectstack-ai#18012

Clause-②: yes

Ruling executed: decision batch objectstack-ai#146 item 5, **letter A** — maintainer
「146 同意」 2026-09-17T13:16Z. Carrier: the changeset
`.changeset/18012-between-blank-endpoint-refused.md` —
`@objectstack/spec` **minor**, body carrying **BREAKING for authored
metadata**, ADR-0087 disposition `registered
filter-between-blank-endpoint-refused`.

## What changed

`$between` now requires two endpoints that are **present and
non-empty**. A blank bound at either side is refused at the authoring
door, and the refusal **names the blank side** — `MIN` / `MAX` plus the
index — because the only measured producer pads a half-typed pair, so
both bounds are present and the author is the one person who cannot see
which one is empty.

```
FROM  FieldOperatorsSchema.safeParse({ $between: [1, ''] })  ->  { success: true }
TO    FieldOperatorsSchema.safeParse({ $between: [1, ''] })  ->  { success: false,
        issues: [{ code: 'custom', path: ['$between', 1],
                   message: 'A blank value is not a valid $between endpoint at index 1
                             (the MAX bound). …' }] }
```

Three spellings, one rule, but only one of them changes what parses:

| spelling | before | after |
| --- | --- | --- |
| `''` | parsed green | refused — **the only behavioural change**; `''`
is a string and the endpoint union accepted it |
| `undefined` | refused with zod's bare `Invalid input` | refused with
the pointed sentence naming the side |
| `null` | refused with the 2026-08-31 ruling's own message |
**unchanged** — it prescribes the null predicate, a different remedy for
a different intent |

The refinement rides the endpoint factory `RangeOperatorSchema` (the
documentation copy) and `FieldOperatorsSchema` (the enforced copy)
already share, so the two cannot drift. The published endpoint
description gained the rule in the same edit — declared = enforced —
which is the whole of the regenerated
`content/docs/references/data/filter.mdx` diff (5 rows, one per
carrier).

The empty-string arm is an element-level `superRefine`, deliberately not
the tuple-level refinement the factory's docblock rules out: a tuple
check does not run once an element has failed, whereas the element check
runs exactly when the union accepted the endpoint, which is precisely
when there is an `''` to report.

## The ADR-0087 half the ruling left to measurement

The ruling asked for a D2 conversion entry and explicitly did not pick
the behaviour: 「the dev measures which the load path already does for a
refused operator and follows that precedent」 (drop the operator, or
refuse at load).

**Measured, on `origin/main` before the change: the load path does
neither.** `applyConversionsToStoredItem` — the one primitive every
stored-row rehydration seam calls — never throws and never validates; it
replays only the positively-recognised lossless transforms in the
conversion registry. A stored view carrying `{ close_date: { $between:
['2026-01-01', ''] } }` comes back as the **same object reference**. No
conversion in the registry drops a filter **operator** either: the three
filter-adjacent entries are two key strips and a key rename.

So the precedent to follow is the one the two nearest narrowings of this
same surface already set — `filter-preset-ordering-comparand-refused`
and `analytics-date-range-array-two-bounds-required`, both of which
decline a D2 conversion because rewriting would be the platform guessing
which bound was meant. **Registered as an ADR-0087 D3 semantic entry,
with no D2 conversion and no stored-metadata rewrite.** Dropping the
operator would be worse than guessing: it deletes a constraint the
author wrote and silently **widens** the result set — the failure mode
`$nin` carries in the same file.

Consequence, stated rather than left to be discovered: the read path
does not re-validate stored rows, so **no stored document becomes
unreadable**. What changes is that re-saving one is refused, at the
endpoint's own path, with the blank side named.

## `migrations/registry.ts`

The ruling's Execution line sequenced this on `registry.ts` after objectstack-ai#18319
/ objectstack-ai#18420. The dispatching seat measured that this no longer applies and
said so on the card: the file's three tables are generated regions fed
one-file-per-entry from `entries/`, and all four PRs said to hold it
each add their own entry file. This PR did the same — **one new file
under `entries/semantic/`, then `gen:migration-registry`**. Nothing was
typed between the markers; the `registry.ts` diff is 86 lines of
regenerated output and `check:migration-registry` proves the
regeneration faithful.

## Verification

Run on `e849c873cd` (the merge of `origin/main` into this branch), heavy
runs serialized through the shared verify lock.

- `pnpm --filter @objectstack/spec test` — **491 files / 14309 tests
passed**.
- `pnpm --filter @objectstack/spec typecheck` — clean (`tsc --noEmit` +
scripts + test-layer ledger).
- `pnpm --filter @objectstack/spec check:generated` — **all 16 generated
artifacts up to date** after the merge. Exactly one was proved stale
during the change (`content/docs/references/**`) and regenerated with
`--fix`, which touched only it.
- `pnpm lint` — repo-wide, exit 0.
- Targeted gates, all exit 0: `check-adr-0087-registration --base
origin/main`, `check-changeset-no-major --base origin/main`,
`check-empty-changeset --base origin/main`, `check:nul-bytes`,
`check:where-matcher`, `check:query-options-erasure`,
`check:test-source-alias`, `check:spec-parsed-alias`,
`check:cross-package-test-inputs`, `check:merge-driver`,
`check:published-files`, `check:objectui-changeset`,
`check:type-check-coverage`, `check:doc-anchors`,
`check:docs-single-h1`, `check:docs-spec-enumerations`,
`check:quick-reference-counts`, `check-doc-frontmatter`,
`check-docs-section-name`, `check-closing-keyword-parity`.
- `check:type-check-debt` — **NOT MEASURED**, exit 3 `PREREQUISITE NOT
MET`: it needs the whole workspace dist closure built, which `lint.yml`
does before the step and this run did not. Not a pass and not a finding.
This diff adds no package and moves no ledger entry.

### Reverse verification — the new assertions are not vacuous

Ablated through `scripts/ablation-replace.mjs`, which proves the
mutation reached disk before the command runs (no `-i` family):

```
anchor  "if (endpoint !== '') return;"   x1 -> x0
blob    d17f958 -> 4450ff1fac67          (the mutation landed)
result  5 failed | 162 passed
restore blob == HEAD d17f958, `git diff HEAD` empty
```

Predeclared direction: **red**, and exactly the five empty-string cases
went red. The `undefined` case, the `null` case and all 162 pre-existing
assertions stayed green — which is what separates "this rule is
enforced" from "this file's tests pass". No build step is involved: the
spec suite resolves `./filter.zod` from source, not from `dist`.

### Fixture sweep

Every `$between` array literal in the tree was read for a blank or
absent bound: **3 distinct sites, none of them parsing through this
schema** — the driver-sql undefined-comparand refusal pin, the
service-analytics filter-normalizer pin, and the `parseFilterAST` pin in
`filter-comparand-shape.test.ts`. No fixture had to be rewritten.
Instrument radius: tracked files this repo's `git grep` matches for
`$between`, scanned for array literals; outside it lie the sibling
`../objectui` checkout (a different repo, and its half is its own card)
and any range built programmatically rather than written as a literal.

## Acceptance notes

- **The runtime door is untouched, and it now disagrees with the schema
door about `''`.** `parseFilterAST` still reads an empty string as a
value — pinned on purpose in `filter-comparand-shape.test.ts` ("refuses
ONLY null — falsy and empty-ish members are values, not absence"), and
that file is outside this card's file surface and outside the ruling,
which scoped the spec half to the schema refinement. Flagged, not filed:
the two doors serve two different populations (an author saving a
document vs a caller handing a where-clause to the engine) and aligning
them is a decision of the same class as this card's, not a seat call.
Carrier if it is ever wanted: the same file that carries the null and
ordering runtime twins.
- **Whitespace-only endpoints still parse.** `{ $between: [' ', 'M'] }`
is green, and there is a positive assertion pinning that, so a later
reader cannot widen the refusal without noticing they are doing it. The
ruling enumerated `''`, `null`, `undefined`; narrowing a published face
past what was ruled is the seat call this card's whole history refuses
to make. Noted, not filed.
- **`FilterConditionSchema` judges no comparand at all** — it is
`z.record(z.string(), z.unknown())` at every field position, so it also
lets the already-ruled `{ $field }` endpoint through. Standing shape,
not a hole this narrowing opened; a test now pins it with that `{ $field
}` control beside it so the green reads as a measurement rather than an
oversight. Noted, not filed.
- **The `Clause-②: yes` line is copied from the dispatch's claim
comment, as the ruling set it.** For the record, this diff carries no
widening tell: no key, enum member, union arm, export row or registry
registration is added, and `check:api-surface` is green with no export
delta. Read strictly against the clause's own question
(「本卡放宽接受集或扩大公开面吗」) the direction is narrowing-only; the direction is
carried in prose and by the changeset's `BREAKING for authored metadata`
banner rather than by rewriting the ruling's word.
- The objectui half — the builder stops padding a half-typed pair — is
objectstack-ai/objectui#9695 and is untouched here. It is safe on its
own and may land either side of this PR.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ectstack-ai#19090)

Fixes objectstack-ai#18177

Clause-②: yes (narrowing)

Executes decision batch objectstack-ai#146 item 4, **letter A** — maintainer 「146 同意」
2026-09-17T13:16Z. `BulkActionParamSchema` becomes strict like its twin
and declares the key measured live on the surface; route B is not built,
route C is not kept.

**Changeset carrier:** `.changeset/18177-bulk-action-param-strict.md` —
`@objectstack/spec` **minor**, body carrying 「Breaking for authored
metadata」.
**ADR-0087 disposition:** `registered
ui-bulk-action-param-unknown-keys-refused` — a D3 structured TODO, not a
D2 conversion, for the reason the majors-15/16/17 strictness entries
give: an arbitrary unknown key has no mapping target.

---

## 1 — Measure first (the ruling's step 1)

### 1a. Which keys the bulk dialog reads off a bulk param after the
spread

**Instrument.** `bulkParamToField` destructures the eleven declared keys
out and spreads the rest onto the field metadata handed to
`getLazyFieldWidget`, so the question is: which keys does a widget read
off that bag? Enumerated by scanning every form-widget module
`getLazyFieldWidget` can return — `objectui@3e4f6324f7`,
`packages/fields/src/widgets/**` (74 non-test modules) — for property
reads off the `field` prop, following the local aliases those modules
assign it (`const config = field as any`, and the chained `lookupField`
→ `fieldMeta` → `cascadeMeta` unwrap in `LookupField`).

**Firing control (so a zero would be a reading).** The positive control
is `dependsOn`: the scan returns it at 6 sites across 5 modules, and
each was read by hand to confirm it is live code and not a comment. The
negative probe (`zzz_nonsense_key_that_no_producer_emits_8755`) returns
nothing.

**Instrument's reachable radius, and a known target outside it.** The
radius is `packages/fields/src/widgets/**` in objectui. It does **not**
reach `packages/fields/src/index.tsx`, and a known target lives there:
`buildValidationRules` reads `field.min` / `field.max` / `field.pattern`
/ `field.required_message` and more. That function is excluded
deliberately, not by accident — it is the react-hook-form path the
object FORM uses, and the bulk dialog does not go through it
(`BulkActionDialog` renders the widget directly). Its keys are therefore
not evidence about this surface. A first pass of the scan that did
include `index.tsx` also over-reported `startsWith` (a string method,
not a field key), which is why the alias-following pass was read by hand
rather than trusted.

**Result — `dependsOn` is live on BOTH widget families reachable from
the dialog:**

| family | reader | what it does with the key |
| --- | --- | --- |
| option widgets — `SelectField`, `MultiSelectField`, `RadioField`,
`CheckboxesField` | `field?.dependsOn` | gates and re-resolves the
offered set through `useCascadingOptions` |
| reference-bearing pickers — `LookupField`, and `UserField` through it
| `cascadeMeta?.dependsOn` | lowers it into a hard candidate filter and
gates the trigger while a named parent is empty |

**And a long tail of widget-config keys is read off the same bag** —
`min` / `max` / `step` (NumberField, SliderField, CurrencyField,
PercentField, RatingField), `accept` / `maxSize` / `crop` / `capture`
(FileField, ImageField), `rows` (TextAreaField, RichTextField),
`precision` / `scale`, `dimensions` (VectorField), `defaultName`
(AvatarField), and the picker knobs `descriptionField` / `idField` /
`allowCreate` / `lookupColumns` / `lookupPageSize` / `lookupFilters` /
`picker` / `subtitle` / `avatarField` (LookupField). ⭐ **`format` is not
among them**, although the module header used to name it beside
min/max/step: no form widget reads it. That discrimination is what makes
the list a measurement rather than a transcription of the header.

### 1b. Census of authored bulk params for keys the schema does not
declare

**Instrument.** Bracket-matches every `bulkActionDefs` array in a tree,
extracts each `params[]` object literal and lists its top-level keys.
**Firing control:** a planted fixture carrying `dependsOn` and the
nonsense key — the instrument reports both and leaves the eleven
declared keys unflagged.

| tree | bulk-param literals | carrying an undeclared key |
| --- | --- | --- |
| `objectstack-ai/objectstack` @ `176b03582e` | 7 | **0** |
| `objectstack-ai/objectui` @ `3e4f6324f7` | 3 | **0** |
| `objectstack-ai/hotcrm` | **NOT MEASURED** | **NOT MEASURED** |

⚠️ **The hotcrm leg is NOT MEASURED, and is not to be read as clean.**
That repository is not reachable from this session, and ⛔ nothing here
infers its contents from the excerpts quoted on the card. The ruling
asked for a census across the four repos and hotcrm; what was reachable
is the two rows above. The migration entry records the same boundary so
an upgrader does not inherit the result.

**Instrument's radius here too:** it finds params written as object
literals inside a `bulkActionDefs` array. A param assembled in a
variable and spread in would be outside it. No such site was seen, but
that is an absence the instrument cannot certify.

⇒ **No authored unknown key was found, so no ADR-0087 *conversion* entry
is owed and there is no stop-and-report.** The registered entry is the
D3 structured TODO for the narrowing itself.

---

## 2 — What the change is

`BulkActionParamSchema` moves from `z.object({…}).passthrough()` to
`strictObject({…})`, and declares `dependsOn`. The rejection is curated
rather than bare:

- **aliases** — the known-divergence spellings now RENAME instead of
riding through: `helpText` → `help`, `description` → `help`,
`defaultValue` → `default`, `reference` → `object`, `referenceTo` →
`object`, `displayField` → `labelField`, `title` → `label`. These are
the same three mappings `toBulkParam` performs when it promotes an
ACTION param, so the authored and promoted directions now agree.
- **guidance** — `field`, `objectOverride`, `visible`, `visibleWhen`,
`carryOver`, `defaultFromRow`, `requiresFeature`, each answered with the
layer that really owns it. ⛔ None of them promises the field-backed
route, because the bulk surface does not have one — that would be the
confidently-wrong prescription this campaign has shipped before.
- **guidanceSet `BULK_PARAM_WIDGET_CONFIG_KEYS`** — one prescription for
the whole measured widget-config family, naming `FieldSchema` as the
shape those keys are real on, and saying in as many words that declaring
the key on the object's FIELD does not reach this dialog either.

### Why the declare set is `dependsOn` and not the whole measured tail

The tail is measurably READ, so declaring it would be defensible on that
half alone. It is not declared because the other half is missing: the
census found no author writing one, and a declared key is published
contract whose removal costs a full retirement kit, while an over-strict
refusal costs one card. The asymmetry decides it. The measured tail is
written into the file beside the guidanceSet so the next reader has the
evidence without re-deriving it, and the residual question is filed
rather than guessed — see Acceptance notes.

**⚠️ The cost is real and is not buried:** those keys were honoured, and
they are refused now. That is the behaviour change the ruling's own
words priced in («a behaviour change for every existing author of a bulk
param, not just for this key»), one-shot, no grace window, no dual
spelling. The census measures the in-corpus breakage at zero.

### What is deliberately NOT closed

`params[].options[]` stays `.passthrough()`. Its openness rests on its
own 2026-08-03 measurement (the option entries are spread verbatim into
the field metadata, where the widgets read `color` / `icon` / `disabled`
/ `visibleWhen`), which this change does not disturb. Closing it by
symmetry with its parent would delete widget config the renderer honours
— the same defect this PR closes one level up. The declared `{ label,
value }` pair is still type-checked.

---

## 3 — A brief premise corrected on measurement

The dispatch named `packages/spec/src/ui/action.zod.ts` as «the twin
whose shape and `.describe()` text you must match». Measured:
**`ActionParamSchema` declares no `dependsOn` at all.** The
single-record dialog reaches the key through the field-backed route
(`resolveActionParams` resolves the object's field definitions), so the
spec's only declaration of this key is **`FieldSchema.dependsOn`**
(`packages/spec/src/data/field.zod.ts`) — which is also the spelling the
card itself names as the one objectui was ruled to honour.

So `action.zod.ts` is the twin for **strictness**, and `FieldSchema` is
the twin for **this key's shape and description**. Both halves are
honoured: the member is byte-for-byte the field-level union (`string` or
a strict `{ field, param }` entry, same alias table), and the
description is the field-level text with ONE sentence appended — a bulk
run holds a selection and not a row, so «other field(s) on the same
record» had to say what the record is here (the dialog's own in-progress
param values, i.e. a sibling param of the same def). ⛔ `action.zod.ts`
is not edited.

A parity pin (`accepts exactly what the FieldSchema twin accepts, and
refuses exactly what it refuses`, 8 cases, asserted equal as a vector
and asserted to contain both verdicts) is what stops the two doors
drifting into dialects.

---

## 4 — Verification

Run against `837234d86b`, this branch's final commit.

| leg | command | result |
| --- | --- | --- |
| build | `pnpm --filter @objectstack/spec build` | exit 0 |
| typecheck + test | `pnpm --filter @objectstack/spec typecheck && pnpm
--filter @objectstack/spec test` | exit 0 — **492 test files, 14493
tests passed** |
| generated artifacts | `pnpm --filter @objectstack/spec
check:generated` | exit 0, all 16 up to date after regeneration |
| ADR-0087 | `node scripts/check-adr-0087-registration.mjs --base
origin/main` | exit 0 — `[BREAKING+clause-②-narrowing] registered
ui-bulk-action-param-unknown-keys-refused (new here)` |
| eslint, whole repo | `pnpm lint` (= `eslint . --no-inline-config`) |
exit 0 — no narrowing claimed, the full run fits |
| control bytes | `grep -naP` over all 14 changed paths, plus `pnpm
check:nul-bytes` | no match / exit 0 |
| derived gate families | `node scripts/pm/dispatch-gates.mjs` over the
real change set, reconciled with `--ran` carrying exit codes | **101
green, 7 NOT MEASURED** |

**The 7 NOT MEASURED, every one a `PREREQUISITE NOT MET` refusal that
needs a repo-wide build this lane does not own** (exit 3, except the
last which exits 1 and says the same thing in words — recorded here
rather than counted as a failure): `check:doc-formula-expressions`,
`check:doc-security-posture`, `check:docs-transcript-drift`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:type-check-debt`, `check:skill-examples`. ⛔ None of them read
anything about this diff; they are declared to CI, not skipped quietly.

**Regenerated, never hand-edited:** `authorable-surface/ui.json` (gains
`ui/BulkActionParam:dependsOn`), `api-surface-declarations/*.txt`,
`content/docs/references/ui/{bulk-action,view}.mdx`, the
strictness-ledger counts, and `migrations/registry.ts` (from the new
one-file entry, via `gen:migration-registry`). ⛔ Nothing was typed
between the generated markers. `authorable-surface.base.json` is
unchanged, as expected — only `gen:authorable-surface-base` writes it.

**Strictness ledger moves the right way:** `ui/` passthrough 3 → 2,
strict 165 → 167; repo total strict 318 → 320, passthrough 4 → 3.

**No in-repo consumer of the narrowed type.** `BulkActionParam` loses
its index signature when the shape closes. Measured: no file outside
`packages/spec/src` imports that type (`packages/cli` and
`packages/spec/scripts` mention it in prose only), so no consumer
typecheck is owed. `packages/cli typecheck` was attempted and refuses on
unbuilt workspace dependencies — the AGENTS.md section-9 stale-closure
signature, unrelated to this diff and left to CI.

---

## Acceptance notes

Everything below was found on the way and is deliberately NOT fixed
here.

1. **To file — should the measured widget-config family become declared
on a bulk param?** After this PR, `min` / `max` / `step` / `precision` /
`scale` / `rows` / `accept` / `maxSize` and the picker knobs are refused
at parse while the widget one seam over would still honour them, and
there is no field-backed route to reach the dialog by. That is a real
authoring gap: an author reading the renderer's vocabulary writes a key
the runtime now rejects. Sized and located by the measurement in §1a.
Dedupe words: `BulkActionParam`, widget config, `min`/`max`/`step`,
`bulkParamToField` spread, field-backed bulk param.
2. **To file — the objectui-side half of this landing.** `objectui`'s
`packages/plugin-grid/src/__tests__/bulkLookupDependsOnReach-8755.test.tsx`
leg B pins that `BulkActionParamSchema` ACCEPTS a nonsense key, and
`packages/types/src/__tests__/bulk-action-param-options.test.ts:139`
parses an authored param through the same schema. Both are correct
against installed 17.4.0 and both turn red the day objectui's spec pin
crosses this release; the first has to be re-judged into a refusal pin
the way this PR re-judged its own. Nothing here breaks objectui's BUILD
— no export is removed or renamed — so this is a coordination note, not
a Post-Task-Checklist-4 blocker. Dedupe words: objectui,
`bulkLookupDependsOnReach-8755`, leg B null reading, spec pin bump, bulk
param strict.
3. **Noted, not filed:**
`packages/spec/src/shared/union-author-message-pins.test.ts` carries a
hand-maintained table of string-or-object union sites, and this PR adds
one (`BulkActionParam.dependsOn`). The file says out loud that nothing
mechanically holds that table equal to the tree and that a standing
re-scan «is deliberately left to its own card», so the gap is already
recorded there. The new site's rendered message is pinned in this PR's
own sibling test instead (surface phrase, rename arrow, and the string
arm's kind mismatch asserted absent). Carrier: the next PR that touches
that table, or the standing-guard card the file already names.
4. **Noted, not filed:** the module header's claim that the catch-all
forwarded `min/max/step/format` was 3-for-4 — no form widget reads
`format` on this path. The header is corrected in this PR rather than
filed, because the sentence lives in the file being edited. Carrier:
none needed.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
objectstack-ai#19374)

Fixes objectstack-ai#19071

Clause-②: no

The runtime filter door now refuses a blank `$between` endpoint, exactly
as the authoring schema door already does.

## The split this closes

`RANGE_ENDPOINT_DESCRIPTION` — the published endpoint contract shared by
both of `$between`'s bounds — has stated since 2026-09-17 (objectstack-ai#18012, batch
objectstack-ai#146 item 5 letter A) that "BOTH are required NON-BLANK: an empty
string, null and undefined are refused, and the refusal names the blank
side". That rule shipped at the schema door only.

Re-measured on this branch's base `2277d1fcd` at 2026-09-20T13:22Z, all
three of the card's probes reproduce, control included:

| probe | before this PR |
|:--|:--|
| `parseFilterAST({ at: { $between: ['', ''] } })` | returned unchanged
— same object reference |
| `parseFilterAST({ at: { $between: ['2026-01-01', ''] } })` | returned
unchanged — same object reference |
| control `parseFilterAST({ at: { $between: [null, 1] } })` | threw
`Operator "$between" on field "at" requires two non-null bounds` |

One published sentence, two truth values. The door that passed it is the
one an embedder reaches by handing a lowered filter straight to a
driver, where the range stops bounding on the blank side while still
reading as a complete two-element range.

## What this changes

`assertListComparandShapes`' `$between` arm refuses `''` and `undefined`
at either bound with `INVALID_FILTER` / 400, naming the blank side (MIN
or MAX plus the index) and carrying the schema door's own two
prescriptions — write the bound you meant, or drop `$between` for
`{"$gte": min}` / `{"$lte": max}` if only one side was ever bounded. The
longest assembled form measures 466 characters against the unrelaxed
500-character client bound, and the bound test grew the new cases.

Order inside the arm is arity, then `null`, then blank, so a pair that
is blank on one side and `null` on the other keeps the message it has
had since 2026-08-31.

## ⚠️ One ruled word could not be implemented as written — please read
this cell

The ruling says the runtime door refuses a blank endpoint "exactly as
the schema door does (empty-after-trim string, `null`, `undefined`)".
Those two halves disagree, and the parenthetical is the one that does
not hold: **the schema door does not trim.**

| endpoint pair | schema door on `2277d1fcd` | this PR's runtime door |
|:--|:--|:--|
| `['   ', 'M']` (whitespace-only) | `success: true` | passes |
| `['\t\n', 'M']` | `success: true` | passes |
| `['', 'M']` | refused, names MIN | refused, names MIN |

`rangeEndpointSchema`'s own comment states the rule as `endpoint !== ''`
and says in as many words: "⛔ Not a trim and not a whitespace rule: the
ruling is the empty string, and widening it here would narrow a
published face further than ruled." `filter.test.ts` then pins
`RangeOperatorSchema.safeParse({ $between: [' ', 'M'] })` green on
purpose, with the comment "this assertion is what keeps a later reader
from widening it without a ruling of their own".

So an empty-after-trim predicate here would have re-opened the very
split this card exists to close — in the opposite direction, with
whitespace passing the authoring door and being refused one step later —
and narrowed a published face further than any ruling has. This PR
implements the operative clause ("exactly as the schema door does", "the
same guidance as the schema door") and leaves whitespace-only endpoints
legal at both doors. ⛔ Nothing is re-adjudicated: if the intent really
was to trim, that is a second narrowing of a published face and wants
its own ruling, and it is one line here plus the `filter.test.ts` pin on
the other side.

## Scope held, deliberately

- ⛔ `RANGE_ENDPOINT_DESCRIPTION` and
`packages/spec/src/data/filter.zod.ts` are not touched — B was refused,
and that file is held by open PR objectstack-ai#19335.
- ⛔ No ADR-0087 transition is registered — C was refused. The
changeset's disposition is `already-registered` against objectstack-ai#18012's
existing entry, which covers this same surface set.
- ⭐ Only the `$between` row of the `filter-comparand-shape.test.ts` pin
is inverted. The `$in` / `$nin` falsy-member rows stand, in place, with
a note saying which row moved and why: falsy VALUES are values, and this
ruling is about range ENDPOINTS. The replacement pin reads BOTH doors
and asserts they agree, rather than restating either.
- Pointer for objectstack-ai#13357: the null-shaped carve-out recorded there is
preserved unchanged — `null` bounds keep their own message and their own
prescription (the null predicate), and are checked first. This PR
narrows nothing that ruling settled; it adds a second,
differently-spelled blank alongside it.

## Verification

Every reading below was taken in this worktree at HEAD `7682aebc` (after
`git merge origin/main`), unless the line says otherwise.

- `pnpm --filter @objectstack/spec build` — green.
- `pnpm --filter @objectstack/spec test` — 502 files / 14696 tests
passed.
- `pnpm --filter @objectstack/spec typecheck` — green (`tsc --noEmit`,
scripts project, and the test-layer debt ledger held at 54 files / 259
errors / 144 pinned signatures).
- `pnpm --filter @objectstack/objectql exec vitest run
src/engine-filter-array-lowering.test.ts
src/engine-comparand-type-door.test.ts
src/query-expression-conformance.test.ts
src/protocol-explicit-filter-field-gate.test.ts` — 4 files / 289 tests
passed. First attempt was a PREREQUISITE failure, not a red: the closure
was unbuilt and the run died on "Failed to resolve entry for package
@objectstack/core". Built `@objectstack/objectql^...` and re-ran.
- Gate family re-derived in this worktree from the real changed paths:
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` — 81 families at this head (75 before the
changeset existed). All 81 run with exit codes captured to disk before
any pipe; `--ran` reconciles 81 derived / 80 run / 1 NOT MEASURED / 0
UNRUN.
- The one NOT MEASURED is `node scripts/check-plugin-teardown-shape.mjs
--self-test`, exit 3: its positive control is pinned to commit
`621a487607881c66b2899b7e3477115229a156b4`, which this shallow checkout
cannot reach; `git fetch --deepen 500` did not bring it. That command
grades the checker's own fixtures, not this diff — the PR-relevant form,
`node scripts/check-plugin-teardown-shape.mjs`, exits 0.
- Three gates first answered exit 3 PREREQUISITE NOT MET on an unbuilt
tree (`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:type-check-debt`) and one hit my runner's own 150-second cap
(`check:query-options-erasure`). All four are green after `pnpm build`,
and `check:type-check-debt` re-measures 4 ledger entries / 53 raw
errors, none above its recorded number.
- `pnpm lint` — the whole repo, `eslint . --no-inline-config`, exit 0 at
`7682aebc`. No narrowing was claimed and none was needed.

## Acceptance notes

Noted while measuring, ⛔ not filed by this dispatch and ⛔ not fixed
here:

- **A `{ $field }` reference as a `$between` endpoint is refused by the
schema door and accepted by the runtime door.** The same two-door shape
as this card, one endpoint spelling over, and already ruled on the
schema side: `RANGE_ENDPOINT_DESCRIPTION` says "A { $field } reference
is NOT an endpoint shape", ruled 2026-08-11 under objectstack-ai#7596. Reproduction on
`2277d1fcd`: `RangeOperatorSchema.safeParse({ $between: [{ $field: 'a'
}, 'M'] })` answers `success: false`, while `parseFilterAST({ f: {
$between: [{ $field: 'a' }, 'M'] } })` returns the filter unchanged. It
is left alone here because its refusal needs its own wording and, being
a second narrowing of a published face, its own ruling — the same reason
this card exists. Dedupe words: `field reference between endpoint` ·
`parseFilterAST runtime door` · `filter-comparand-shape` · `7596
endpoint shape` · `two doors disagree`.
- **`@objectstack/hono` fails its dts build under `turbo run build
--concurrency=2 --filter='./packages/*' --filter='./packages/*/*'`**,
with `TS7016: Could not find a declaration file for module
'@objectstack/plugin-hono-server'`, and builds clean under `pnpm build`
on the same tree minutes later. Reads as a build-ordering race in a
shared warm cache rather than a defect in the tree; recorded because the
gate prescription for `check:type-check-debt` names that exact command.
Carries no reproduction that does not depend on cache state, so it is an
observation, not one of the three filing classes.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…ction-*, data-* and element-* migration entries states each lesson in words, not tracker numbers (stage 4) (objectstack-ai#20509)

Part of objectstack-ai#20233
Stage 4: the datasource-, filter-, action-, data- and element- families.

Clause-②: no

**Stage 4 of a staged card.** The card stays open for later stages; this
PR carries no closing keyword. Text only: no entry id, `surface`, `from`
/ `to`, conversion or matching logic moves, and the chain rewrites
exactly what it rewrote before.

## What this does

`os migrate meta` prints every ADR-0087 semantic entry it crosses as one
block: `⚠ [protocol N] SURFACE → REPLACEMENT`, then `why:` (the entry's
`reason`) and `verify:` (its `acceptanceCriteria`). AGENTS.md's
runtime-string rule applies to all of it: 「Runtime strings — refusal
prose, prescriptions, anything an author is shown — carry no tracker
number (`pnpm check:doc-authoring`): the lesson goes into the text.」
Form **D** of ruling C+D on objectstack-ai#19123 (`5749154545`) sets the shape: the
lesson in words, and no number, dead or alive; a cross-repo number
(`cloud#N`, `objectui#N`) is still a tracker number.

This stage covers the next five families, `datasource-`, `filter-`,
`action-`, `data-` and `element-`: **150 sites → 0** in the three prose
fields, across 33 entry files. None of the 37 entries in these families
carries a tracker id in `surface` (ruling A of the stage-1 ACCEPT,
`5858839916`, is checked and has nothing to do here). Each site now says
what the cited ruling, measurement or fix decided. ADR ids stay.
`registry.ts`, `spec-changes.json` and `docs/protocol-upgrade-guide.md`
are regenerated from the entries (`gen:migration-registry`,
`gen:spec-changes`, `gen:upgrade-guide`), never hand-edited. The pin now
holds eleven families.

## Census — tracker ids in the author-shown fields

**Instrument.** The stage-2 AST instrument, re-written to its published
description (the stage-2 and stage-3 copies lived in removed
scratchpads): a TypeScript-AST walk over every
`packages/spec/src/migrations/entries/**/*.ts`. For each entry object
literal it evaluates the string value of `replacement`, `reason`,
`acceptanceCriteria` and (counted separately) `surface`, joining string
literals with `+`, then counts `#` followed by 4 or 5 digits at a word
boundary. **Validated first** by reproducing earlier readings on their
own trees (extracted with `git archive`): on `443b2f4fdc` (stage 1)
`engine-` 67, `datasource-` 43, `filter-` 30, `action-` 26, `element-`
25, `data-` 24, whole tree 266 entries / 1,016 sites / 9 `surface`; on
`0d7ed5a378` (stage 3 landed) whole tree **711** sites / 7 `surface` —
every figure equal to the stage-1 census and to stage 3's after-count.
Unevaluable fields: 0.

**Controls, same run.**
- **Lit:** `17.aggregation-node-distinct-retired.ts` reads 7 sites
(replacement 1, reason 6), before and after.
- **Dark (comment lines):** 823 `//` / docblock lines in entry files
carry a tracker id, and none is counted; 823 before and after. Comment
lines are the sibling card objectstack-ai#20234's surface, and this PR touches none
(proved below).
- **Dark (field boundary):** the 7 `surface` sites left in the tree
(other families) count 0 in the three-field total.

**Base `fc0db22b`:** `datasource-` 9 entries, **43** sites (3 / 38 / 2);
`filter-` 11, **32** (1 / 30 / 1); `action-` 6, **26** (0 / 23 / 3);
`element-` 5, **25** (2 / 19 / 4); `data-` 6, **24** (0 / 24 / 0).
**150** sites (6 / 134 / 10) in 33 of the 37 entries; 88 distinct ids
(80 bare, 8 `objectui#`, 2 `cloud#`). Whole tree: 310 entries, **721**
sites (711 after stage 3, plus entries `main` added since in other
families), 7 `surface`.

**After this PR:** all five families **0**; the six earlier families
still 0; whole tree **721 → 571**; `surface` 7 (other families). The
PM's rough line count (156 sites, 33 files) is a wider instrument; the
AST reading is 150 in the same 33 files.

| entry | sites (replacement / reason / acceptanceCriteria) | short
`#NN` |
|---|---|---|
| `17.action-descriptor-is-async-retired` | 2 (0 / 2 / 0) |  |
| `17.action-descriptor-resume-authority-default-flip` | 10 (0 / 7 / 3)
| |
| `17.action-session-roles-to-positions` | 12 (0 / 12 / 0) |  |
| `17.data-driver-find-stream-retired` | 1 (0 / 1 / 0) |  |
| `17.data-driver-query-omit-object` | 9 (0 / 9 / 0) |  |
| `17.data-engine-batch-retired` | 1 (0 / 1 / 0) |  |
| `17.data-field-changed-event-retired` | 4 (0 / 4 / 0) |  |
| `17.datasource-config-inline-credential-refused` | 1 (0 / 1 / 0) |  |
| `17.datasource-config-placeholder-refused` | 5 (0 / 5 / 0) |  |
| `17.datasource-config-url-userinfo-refused` | 4 (0 / 4 / 0) |  |
| `17.filter-regex-options-retired` | 8 (0 / 8 / 0) |  |
| `18.action-bulk-dispatch-contract-undeclared` | 1 (0 / 1 / 0) |  |
| `18.action-engine-facade-find-query-envelope` | 1 (0 / 1 / 0) | 1 |
| `18.data-file-value-duration-unit-in-key` | 6 (0 / 6 / 0) | 1 |
| `18.data-nosql-query-options-timeout-unit-in-key` | 3 (0 / 3 / 0) | 1
|
| `18.datasource-config-mongo-options-credential-refused` | 5 (0 / 5 /
0) | |
| `18.datasource-config-postgres-url-unparseable-refused` | 4 (0 / 4 /
0) | |
| `18.datasource-config-url-query-credential-refused` | 4 (0 / 4 / 0) |
|
| `18.datasource-credentialsref-mongo-composed-no-username-refused` | 10
(2 / 7 / 1) | |
| `18.datasource-credentialsref-mongo-url-no-user-refused` | 10 (1 / 8 /
1) | |
| `18.element-data-source-and-object-block-filter-rule-array` | 11 (1 /
9 / 1) | 1 |
| `18.element-number-filter-rule-array` | 7 (0 / 6 / 1) |  |
| `18.element-record-picker-filter-rule-array` | 7 (1 / 4 / 2) |  |
| `18.filter-between-blank-endpoint-refused` | 3 (0 / 3 / 0) | 1 |
| `18.filter-between-field-reference-endpoint-refused` | 5 (1 / 4 / 0) |
|
| `18.filter-comparand-types-and-widget-nested-slots-refused-at-save` |
2 (0 / 2 / 0) | |
| `18.filter-equality-array-comparand-refused` | 1 (0 / 1 / 0) |  |
| `18.filter-equality-array-comparand-refused-at-save` | 1 (0 / 1 / 0) |
|
| `18.filter-icontains-comparand-refused-at-parse` | 3 (0 / 3 / 0) |  |
| `18.filter-ne-array-comparand-refused` | 1 (0 / 1 / 0) |  |
| `18.filter-preset-ordering-comparand-refused` | 4 (0 / 4 / 0) |  |
| `18.filter-query-face-comparands-refused-at-save` | 1 (0 / 1 / 0) |  |
| `18.filter-text-operator-declared-type-refused` | 3 (0 / 2 / 1) | 1 |
| **total, 33 entries** | **150 (6 / 134 / 10)** | **6** |

## Text only — proved by a base-vs-head AST comparison

For every entry file this PR changes, both versions (`fc0db22b` and the
head) are parsed and three things are compared token for token: every
import declaration, every property other than the three prose fields (so
`id`, `surface`, `from` / `to` and any matcher, by evaluated value), and
every comment token in the file. **33 files compared, 0 with a non-prose
change.** The instrument is shown able to fail first: on an in-memory
copy it reports DETECTED for a mutated `id`, a mutated comment and a
mutated `surface`. So none of objectstack-ai#20234's comment lines moved, and no
entry's identity or matching moved.

## Every citation read, and what the text now says

Each cited id was read with a single-card REST read (body plus the
ruling, measurement or landing comments), resolved against the
repository its sentence names. Ids are in code spans so this body posts
no cross-references. **10 bare ids answer 404** on both the issues and
the pulls endpoint (re-probed with a 200 control, `14478`), and **2
`cloud#` ids are unreadable from this session** (403); those sentences
are rewritten from what `main` records, listed in Acceptance notes.

**`datasource-` (14 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `7990` | Maintainer, 2026-08-12, Option A: close inline credentials
per artefact (each schema refuses at publish and diverts to its existing
mechanism); the heuristic `sys_metadata` write guard parked. | "The
maintainer ruled on 2026-08-12 to close that per artefact: each schema
that admitted an inline credential refuses it at publish …"; "the
inline-credential closure" |
| `8078` (PR) | The spec half of that ruling; measured that `${…}`
placeholders reach the client verbatim and that `config.url` still took
the secret. | "measured by the credential census while the
inline-credential refusal was being built"; "measured when the
inline-credential refusal was built" |
| `8082` | Maintainer, 2026-08-12, option A: refuse URL userinfo at
publish through one shared value-level parse; runtime DSNs unaffected. |
"The maintainer ruled on 2026-08-12 (Option A) to refuse the URL
userinfo password at publish, through one value-level parse the driver
schemas share" |
| `8336` | Maintainer, 2026-08-13, direction 2 of two: refuse `${…}` at
publish; implementing resolution rejected. | "The maintainer ruled on
2026-08-13 for the second of two directions: refuse the syntax loudly at
publish" |
| `8337` | The credential-bearing URL query parameter refusal, one
syntax over from userinfo. | "the credential-bearing URL query
parameters"; "the query string was the third spelling of the identical
secret, one syntax over" |
| `8876` | **404** — see Acceptance notes. | "the asymmetry the URL
grammar keeps between its two userinfo halves — a username is not
credential material" |
| `8696` | **404** — see Acceptance notes. | "measured when the bound
secret was made to reach the mongo client on its URL branch"; "`new
URL()` rejects the multi-host form outright, and the mongo arm hands the
authored URL to its client untouched"; "the defect class closed when
each DSN branch was made to inject the bound secret its composed branch
already used" |
| `9041` | **404** — see Acceptance notes. | "the sibling URL-branch
entry, `datasource-credentialsref-mongo-url-no-user-refused`"; "this
URL-branch refusal" |
| `9147` | The composed-branch twin: bound secret + no `url` + no
`username` refused, inheriting the URL-branch ruling. | "this
composed-branch refusal" |
| `7314`, `7385`, `8152`, `8875` | The driver-factory arms that dropped
something declared: the turso loader's install remedy and half its
config, the other optional arms' missing-package remedy, turso's
never-read bound secret, and (`8875`, **404**) the mysql DSN branch. |
"the family of driver-factory arms, closed one driver at a time, that
each dropped something declared without a word: the optional-driver arms
that answered a missing package with no remedy, the turso arm that never
read its bound secret, and the mysql and mongo DSN branches that
discarded one" |
| `8873` | **404** — see Acceptance notes. | "the postgres equivalent is
judged on its own client's measurement, never inherited" |

**`filter-` (24 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `4706` | Maintainer, 2026-08-06, option B: retire `$regex` loudly and
add `$icontains`; five-backend true regex (A) excluded (turso remote
cannot, no business pull). | "the maintainer's 2026-08-06 ruling (option
B: retire `$regex` loudly and add `$icontains`, rather than make five
backends agree on one regex dialect)" |
| `5701`, `5702` | The contract half and the driver half of that ruling.
| "the retirement's own contract-half change"; "the contract half (the
`$icontains` declaration, …)"; "the driver half" |
| `6148` | **404** — see Acceptance notes. | "the gate that makes a
breaking changeset state its ADR-0087 disposition" |
| `18012` | **404** — see Acceptance notes. | "Maintainer ruling A of
2026-09-17: a blank `$between` endpoint is refused at the authoring
door, and the refusal names the blank side." |
| `13495` | driver-memory's reference matcher answered a null-bounded
range with every valued row. | "The reference matcher had already been
taught to survive the null-bound form of exactly this" |
| `objectui#9695` | The console filter builder stops padding a
half-typed pair with an empty string. | "is a change to the console's
own filter builder and lands on its own schedule" |
| `7596` | Maintainer, 2026-08-11, ADR-0049 REMOVE: no `{ $field }`
endpoint in either `$between` union. | "Maintainer ruling of 2026-08-11
on column-reference range endpoints, ADR-0049 enforce-or-remove:
REMOVE"; "reviewed and accepted with that ruling" |
| `7713` (PR) | Shipped that removal with a not-required disposition. |
"the 2026-08-11 changeset carried the disposition not-required" |
| `5222` | `$field` compiled to a column-to-column comparison on the SQL
faces. | "the position the column-to-column comparison compiles on every
face" |
| `19377` | **404** — see Acceptance notes. | "filed as an entry of its
own rather than as an already-registered rider" |
| `20116` | The collector for the family "the save door accepts
comparand shapes the query faces refuse", closed in stages. | "the
second stage of closing the family of comparand shapes the save door
accepted and the query faces refused"; "the family of comparand shapes
…" |
| `7872` | Maintainer, 2026-08-12: the shared face accepts `string` /
`number` / `bigint` / `boolean` / `null` / `Date` and refuses everything
else. | "the accepted set the maintainer ruled on 2026-08-12: string,
number, bigint, boolean, null and Date" |
| `19889` | Director seat, 2026-09-24, option A: the schema door refuses
what the compile face refuses (the standing array-equality refusal
applied to it). | "Ruled on 2026-09-24 (option A), applying the standing
refusal of an array in the equality slot to the schema door" |
| `19757` | Maintainer, 2026-09-23, option 乙: refuse an equality-slot
array at the shared face; 甲 (declare array equality) and 丙 (document the
divergence) rejected. | "Maintainer ruling of 2026-09-23 (option 乙 —
rather than declaring an array equality the SQL-family backends would
have to invent, or documenting a divergence that stays silent on one
backend)" |
| `19514`, `objectui#9050` | The protocol half of the maintainer's
2026-09-20 ruling C′ on the console's filter converter, rule 1 「the
differences are the protocol's to close」. | "The protocol half of the
maintainer's 2026-09-20 ruling (option C-prime) on the console's filter
converter, whose first rule reads, verbatim and untranslated: 「the
differences are the protocol's to close」" |
| `18113` | Published the text-comparand refusal predicate and reason
beside `FILTER_TEXT_CASES`. | "the pair published in this package beside
FILTER_TEXT_CASES for exactly this reason" |
| `19886` | Director seat, 2026-09-24, option A on standing text: `$ne`
with an array refused at the shared face and the schema door. | "Ruled
on 2026-09-24 by the director seat, on the standing contract text
(option A)" |
| `8690` | Ruled 2026-08-15: option B (engine door refuses an
uninterpretable temporal comparand) with option C (the preset refusal at
authoring) alongside. | "The authoring half (option C) of the
maintainer's 2026-08-15 ruling on uninterpretable temporal comparands,
ruled alongside the engine door (option B)"; "measured on the defect
report" |
| `8808` (PR) | The engine door. | "before the engine door and a 400
after" |
| `15661`, `15773` | Maintainer, 2026-09-05, C-deny: refuse a text
operator over a never-string declared type, over the existing type sets;
landed at the engine seam. | "Maintainer ruling of 2026-09-05 (option
C-deny: refuse now, over the type sets the contract already declares,
minting no new vocabulary), landed at the engine seam" |
| `14079` | Ruling A: a stored non-string value never satisfies a
positive text operator and satisfies `$notContains`. | "(a stored value
that is not a string never satisfies a positive text operator and
satisfies `$notContains`)" |

**`action-` (19 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `6748` | Retire `isAsync`: zero readers on a fresh three-repo
measurement. | "a fresh three-repo measurement (taken when the key was
filed for retirement, and re-run at pickup)" |
| `6667` | Enforce `supportsPause` at runtime. | "The sibling took the
ENFORCE leg of the same ruling" |
| `3801` | The generic resume route needs an authorization gate keyed on
the suspended node. | "The generic resume route's authorization gate
keys on the SUSPENDED NODE" |
| `3823` | The revise-window `wait` pause is service-owned but inherited
`'any'`. | "The revise-window incident decided the direction" |
| `4484`, `5540`, `6011` | The three retirements whose disposition this
one shares. | named by their entry ids, which the sentence already
carried |
| `5561` | `resumeAuthority` defaulted to `'any'` — fail-open by
omission. | the sentence already states it; ADR-0019's resume-seam
addendum named by date and title |
| `5703` | `supportsPause` / `isAsync` were zero-reader declarations. |
"no longer the declaration nothing enforced" |
| `5613` | Maintainer, 2026-08-06, contract-first ("C skeleton + A
semantics"): declare the shape as it stands, then rename on the typed
face. | "The maintainer ruled contract-first on 2026-08-06 (…: declare
the shape as it stands first, then rename on the typed face)"; "the
runtime half of the same ruling" |
| `5697`, `5779` | Phase 1 (the schema) and phase 2's spec half (the
canonical key and the alias). | "phase 1 declared …"; "`positions` is
now the canonical key …" |
| `5050` | The hook-side `roles` removed outright. | "(removed
outright)" |
| `3280`, `3290` | The hook `ctx.session.tenantId` alias: deprecated,
then removed in the next major. | "the hook `ctx.session.tenantId`
alias: deprecated first, removed in the next major" |
| `4579`, `4657` | The `openApi31` and `activationEvents` retirements. |
named by their surfaces, which the sentence already carried |
| `17319` | Ruled 2026-09-12, A: an action declares its dispatch
contract; B (unify the two wirings) refused. | "the 2026-09-12 ruling
that made an action declare its dispatch contract refused exactly that
option" |
| `14175` | The earlier typing fix that gave `find` the filter alone. |
"the parameter shape an earlier typing fix chose (the filter alone),
ruled by the director seat on 2026-09-12, with the maintainer's
agreement" |

**`data-` (15 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `4484`, `4618`, `4673` | The three retirements, each already stated by
its own entry. | trailing ids dropped (ADR ids kept) |
| `4639` | Multi-record predicate writes got `data.records.*` events. |
"since multi-record predicate writes were given events of their own";
"the way bulk writes were given theirs" |
| `3196` | Webhook triggers trimmed to producers that exist. | the
sentence already states it |
| `cloud#1053`, `cloud#1030` | **403 here** — see Acceptance notes. |
"20 such sites were measured in the downstream cloud codebase, and a
`$like` the type layer would have caught reached runtime there through
exactly that hole" |
| `6350` | The stock reconciliation of the v17 train's breaking
changesets against the ledger; this change was its control sample and
was itself an omission. | "Registered by the stock reconciliation that
compared the breaking changesets already on the v17 release train
against this ledger. This change was that audit's CONTROL sample …";
"(backfilled by that reconciliation)" |
| `5181` | The `DriverQuery` narrowing itself. | "this change"; "this
narrowing" |
| `6321`, `6083` | Two later call-parameter changes, both registered
(`6083` **404**; `main` records it as ADR-0122 phase 2). | "Two later,
smaller driver call-parameter changes both registered" |
| `18669` | Maintainer, 2026-09-17, ruling A: rename the last two
duration keys no closed duration type could express, no new closed type,
no narrowing. | "Maintainer ruling A of 2026-09-17 on the last two
duration keys no closed duration type could express" |
| `18122` | Published `DurationMs` / `DurationSeconds` beside `EpochMs`,
the unit set derived from six genuine duration rows. | "published beside
`EpochMs` as a closed duration type"; "one of the six genuine durations
the closed types' unit set was derived from" |
| `14478`, `15680` | Ruling B of 2026-09-02 on duration units; the
data-directory rename round. | "Maintainer ruling B on duration units
(2026-09-02)"; trailing ids dropped |

**`element-` (17 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `objectui#6206` | Maintainer, 2026-08-25, option B: one filter
orthography platform-wide — the `ViewFilterRule` array, not a
record-shaped exception; measurement-first binding. | "the maintainer's
2026-08-25 ruling, option B: …" (three entries); "the console-side half
of this convergence" |
| `15442`, `15449` | Ruled 2026-09-06, option A: converge the binding
and the four `object-*` doors family-wide, one entry; exception and
mixed rejected. | "ruled 2026-09-06, option A: converge the binding and
the four block doors family-wide, under one entry, rather than record an
exception"; "a gap measured on its own" |
| `7751` | Maintainer, 2026-08-12: the `object-*` block props enter
`ComponentPropsMap`. | "a read-point record derived from the renderers
on 2026-08-13, when the `object-*` blocks first got props schemas in the
map" |
| `objectui#6948` | The console's filter converter had no branch for
`$and` / `$or` / `$not`. | "(the console's filter converter had no
branch for them)" |
| `15828` | `POST /analytics/query` refused the array `where` the
adapter sent. | the sentence already states it; "the runtime route's
refusal of the array corrects it here" |
| `16626` | **404** — see Acceptance notes. | "parked behind a bump of
that pin" |
| `objectui#7754`, `objectui#7752`, `objectui#6828` | The console
adapter lowers an array analytics filter before the wire; `aggregate()`
runs `translateFilterArray`. | "the console adapter was changed so
`ObjectStackAdapter.aggregate()` runs the same `translateFilterArray`";
"The adapter-side lowering lands in the console's own repository." |
| `5158` | Maintainer, 2026-08-04, ruling C: `FilterArray` is input-only
sugar with one lowering seam (`parseFilterAST`). | "since the
maintainer's 2026-08-04 ruling C declared the array input-only sugar
with one lowering seam" |
| `5334` | The in-process analytics door, added when an array `where`
was silently dropped. | "(added when an array `where` was found silently
dropped on the analytics path)" |
| `17321` | Ruled 2026-09-12, B: a partial D2 conversion for the
losslessly mappable record forms; combinators pass through, named. |
"(ruled 2026-09-12, option B: convert what maps losslessly and name what
does not, rather than leave every stored row to its next save or flatten
combinators)" |
| `14406` | Converged `element:record_picker`; its census pin asks
whether any `filter` door refuses the array. | "the twin of the census
pin that asks whether any `filter` door still refuses the array"; the
2026-08-25 Option-A ordering ruling stated as "measure the consumer's
read path before the contract moves" |
| `12039` | `element:number` converged. | "after `element:number`
converged" |
| `objectui#7663` | The console registry's `inputs.filter` flip for the
picker. | "a console-side change filed in the objectui repository,
blocked on that release" |
| `15829` | The dashboard widget `filter` location, a finding of its
own. | "a different family, judged on its own" |

## Pin — `packages/cli/test/migrate-meta-engine-guidance.test.ts`,
widened

`COVERED_PREFIXES` is now `engine-`, `ui-`, `plugin-`, `driver-`,
`kernel-`, `system-`, `datasource-`, `filter-`, `action-`, `data-`,
`element-` (`data-` does not select `datasource-` or `dataset-`: the
match is `startsWith('data-')`). The `REWRITTEN` floor rises from **55
to 88** ids: the 33 entries of this stage that carried a tracker id. The
three `it` blocks are textually unchanged: the detector is exercised on
both sides, every covered prefix must select an entry, and each covered
block is found verbatim in the real CLI's stdout before it is asserted
clean. The file keeps its stage-1 name; the header lists the eleven
covered families.

## Ablation — the widened pin can fail on a new-family block

From committed state, HEAD `d8bcc46a`, in one lock turn, with
`scripts/ablation-replace.mjs` in wrap mode (it owns the mutation's
restore trap; the leg script adds its own `trap … EXIT INT TERM` that
restores `registry.ts` from `HEAD` by absolute path and checks the blob)
and `scripts/ablation-dist-preflight.mjs` gating each leg. The bundle is
built from the generated `registry.ts`, so that is the file mutated.
- **Mutation.** In `registry.ts`, the `acceptanceCriteria` of
`datasource-credentialsref-mongo-url-no-user-refused`: anchor `parse
reports this URL-branch refusal.` → `parse reports the objectstack-ai#9041 refusal.`
The tool read anchor 1 → 0 and replacement 0 → 1, blob `7001c102` →
`6f3f81ff`.
- **Mutate leg.** Spec build exit 0. Preflight: marker present in 4
built files. Pin: **red**, `1 failed | 2 passed` —
`datasource-credentialsref-mongo-url-no-user-refused: the printed
guidance cites a tracker id: expected 'objectstack-ai#9041' to be undefined`.
- **Restore.** Tool-proven: blob `7001c102` == HEAD, `git diff HEAD`
empty.
- **Restore leg.** Spec build exit 0. The `--absent` preflight found the
marker in none of 224 built files, with the working tree clean against
HEAD. Pin: **green**, `3 passed`. Whole tree afterwards: 0 dirty paths.

## Verification

Final head **`d8bcc46a`**. Every heavy run went through
`scripts/pm/os-verify-lock.sh` (`VERDICT command-exit 0` on each turn;
per-step exit codes recorded separately). Where a line was taken at
`95fb97ea` it says so: the one commit after it (`d8bcc46a`) changes one
entry sentence and the three generated projections, and every reader of
that text was re-run at `d8bcc46a`.

- **Build:** `pnpm exec turbo run build --concurrency=2
--filter='@objectstack/cli^...'` gives `Tasks: 58 successful, 58 total`
(at `95fb97ea`); the spec package was rebuilt at `d8bcc46a` in both
ablation legs (exit 0).
- **Pin:** at `d8bcc46a`, the ablation's restore leg:
`test/migrate-meta-engine-guidance.test.ts` `3 passed`. With its
neighbour at `95fb97ea`: `pnpm --filter @objectstack/cli exec vitest run
--project integration --maxWorkers=2
test/migrate-meta-engine-guidance.test.ts
test/migrate-meta-default-range.test.ts` gives `Test Files 2 passed`,
`Tests 10 passed | 1 skipped` (the skip is the default-range file's own
`skipIf`). Re-run with its neighbour at `d8bcc46a`: `Test Files 2
passed`, `Tests 10 passed | 1 skipped`. One earlier run at this head is
void and is not counted: it spawned the CLI while a concurrent lock-free
gate was rebuilding the package `dist/` trees (`MODULE_NOT_FOUND`, both
files); the re-run is the reading.
- **Spec, the whole `local` project** (at `95fb97ea`): `pnpm --filter
@objectstack/spec exec vitest run --project local --maxWorkers=2` gives
`Test Files 572 passed (572)`, `Tests 16789 passed | 1 todo`. **The
`repo` project** (at `d8bcc46a`): `Test Files 38 passed`, `Tests 690
passed`. **The readers of the changed sentence** (at `d8bcc46a`):
`src/migrations`, `src/ui/action-params.test.ts`,
`src/ui/filter-rule-array-guidance.test.ts` give `Test Files 5 passed`,
`Tests 229 passed`.
- **CLI unit** (at `95fb97ea`, no CLI file changed after):
`test/vitest-tiers-partition.test.ts` and
`src/utils/spec-release-changes.test.ts` give `Test Files 2 passed`,
`Tests 28 passed`.
- **The call-spelling census that reads `registry.ts`:** `pnpm --filter
@objectstack/driver-sql exec vitest run --maxWorkers=2
src/sql-driver-query-signature.test.ts` gives 15 passed.
- **Typecheck** (at `95fb97ea`; the last commit edits string literals
only): `pnpm --filter @objectstack/spec typecheck` exits 0 (test layer:
53 files / 251 errors held in its ledger); `pnpm --filter
@objectstack/cli typecheck` exits 0 (3 files / 28 errors held).
- **Gate families:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` derives **90** families at `d8bcc46a`
(the same set it derived at `95fb97ea`). `--ran` over the recorded exit
codes reads **90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN**, all exit 0.
They include `check:doc-authoring` ("16735 customer-facing string(s)
across 1167 spec sources clean"), `check:issue-citations`,
`check:migration-registry` ("registry.ts is current (310 semantic, 230
retired-key, 206 retired-def)"), `check:spec-changes`,
`check:upgrade-guide`, `check:generated` ("All 15 generated artifacts
are up to date"), `check:org-identifier`, `check:nul-bytes`,
`check:dual-build-cjs-loads` (104 require entry points across 66
packages load), `check:type-check-debt`, `check:adr-0087-registration`
and `check:changeset-no-major`.
- How the reading was assembled: a container restart cut the pass at
`d8bcc46a` after 30 families; the other 60 were run afterwards on the
same head, and `check:type-check-debt`, whose first resumed run lost a
package build to an OOM kill (exit 3, `PREREQUISITE NOT MET`), was
re-run alone and exited 0 ("4 ledger entr(ies) re-measured … none above
its recorded number").
- The earlier full pass at `95fb97ea` read 88 exit 0,
`check:dual-build-cjs-loads` exit 3 (dists not yet built) and
**`check:org-identifier` exit 1**: my rewrite of the action-session
entry had spelled the removed hook read literally. `d8bcc46a` names that
alias in words instead, and the gate reads OK there.
- **Lint (a proven narrowing, not the repo-wide run, which is CI's):**
`eslint --no-inline-config --format json` over the 36 changed `.ts`
files at `d8bcc46a` reports 36 files, 0 errors, 0 warnings.
- The population is read from `eslint.config.mjs`:
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`, and all 36
are in it (no file-ignored warning).
- Invariance: the config enables no type-aware linting (no
`parserOptions.project`, no typed rules), so a text edit cannot move the
verdict on a file it does not touch.
- **Mergeability:** `main` moved 10 commits past the base, to
`4a1df196`; the only ones under `packages/spec/src/migrations/` are the
landed `20488` (two entries in the `field-` and `ui-` families, plus
`registry.ts`). A driver-free bare-clone `merge-tree --write-tree` of
`d8bcc46a` against `4a1df196` exits 0 with no conflicted path, and the
census over that merged tree reads 0 sites in all eleven covered
families, so `main` was not merged in; CI's merge ref runs the registry
gates on the merged tree.

## Acceptance notes

- **Ten dead ids, rewritten from the code on `main`.** Each answers 404
on both the issues and the pulls endpoint, with a 200 control (`14478`).
- `8696` / `8875` / `8873` (the bound secret reaching the mongo, mysql
and postgres clients): `service-datasource`'s
`default-datasource-driver-factory.ts`, its CHANGELOG (the mongo
URL-branch entry says it "closes the last arm of the family" the other
cards closed one driver at a time; the mysql DSN fix is `{ uri, password
}`) and `bound-secret-dsn-branches.test.ts`, which pins the bound secret
outranking `options.auth`. `common.zod.ts` and
`pg-url-grammar.server.ts` record why the shared helpers must not parse
(mongo's multi-host form, which `new URL()` rejects).
- `8876`: `common.zod.ts` (`urlUserinfoUsername`, the username half of
the same grammar; a username is not credential material).
- `9041`: `datasource.zod.ts`
(`CREDENTIALS_REF_MONGO_URL_NO_USER_REFUSED` and its docblock); the text
now names the sibling entry by id.
- `6148`: `scripts/check-adr-0087-registration.mjs`'s header (a
declared-breaking changeset must state its ADR-0087 disposition in
writing).
- `18012`: `.changeset/18012-between-blank-endpoint-refused.md` and
`filter.zod.ts` (ruling A of 2026-09-17: a blank bound refused at the
authoring door, the blank side named).
- `19377`: `.changeset/19377-between-field-endpoint-runtime-door.md` and
`filter-comparand-shape.ts` (the runtime door brought under the
2026-08-11 removal).
- `16626`: `.changeset/filter-orthography-binding-and-object-blocks.md`
on `main` records it as the objectui pin bump the element convergence
was parked behind; the text now says "a bump of that pin", and the pin
sha it names is unchanged.
- `6083`: `main` records it as ADR-0122 phase 2 (the spec CHANGELOG);
the sentence keeps its claim ("two later, smaller driver call-parameter
changes both registered") without the numbers.
- **Two cross-repo ids this session cannot read.** `cloud#1053` and
`cloud#1030` answer 403 (`objectstack-ai/cloud` is not reachable here,
and attaching it was refused). The sentence was rewritten from what this
repository records: `packages/spec/CHANGELOG.md` (the published
`DriverQuery` entry: 20 measured `as any` sites downstream, and a
`$like` that reached runtime through that hole) and the `5181` card body
("cloud 实测 20 处 cast … cloud#1030 的 `$like` 本可在类型层拦住"). Nothing the
cloud cards add beyond that is claimed.
- **Short numbers and ruling-record ids went too (invisible to the
regex).** Six decision-batch numbers (`objectstack-ai#43` ×2, `objectstack-ai#55`, `objectstack-ai#123`, `objectstack-ai#146`,
`objectstack-ai#151`), three ruling-record comment ids and one `batch 217 item 3`
spelling are numbers an author is shown and cannot follow, so each is
dropped. So are the maintainer's batch acknowledgements 「146 同意」 and
「217 同意」 and the bare 「同意」 beside `objectui#6206` (×3), `15442` and
`14175`: they record only that a batch was approved, and each sentence
now states the ruling's date and content instead. The one verbatim quote
that carries a lesson, 「the differences are the protocol's to close」, is
kept untranslated.
- **"issue NNNN" / "PR NNNN" spellings, checked by hand.** A scan of the
five families' evaluated prose for any run of three or more digits and
for `issue` / `card` / `PR` / `batch` / `record` / `summon` plus a
number now finds only HTTP statuses, ports and example values. The two
`PR #…` citations were `#`-spelled, so the instrument saw them.
- **One test pinned a removed tracker number.**
`packages/spec/src/ui/action-params.test.ts` asserted `reason` matches
the `5613` number; it is re-pinned on the sentence that carries the
ruling (`/ruled contract-first/`). No other test reads these entries'
prose for a tracker id (a grep of test files for the 88 cited numbers
finds only comment lines and assertions on other runtime strings).
- **No open PR touches these five families.** Read twice. At
2026-09-28T19:04Z: 11 open PRs' file lists (227 rows). Again at 20:28Z,
just before opening this one: 8 open PRs (195 rows; the Version Packages
PR `17076` skipped both times), none carrying a
`migrations/entries/semantic/NN.(datasource|filter|action|data|element)-*`
file. PRs `20504`, `20503`, `20460` and `20458` add or edit entries in
other families (`turso-`, `view-`, `stack-`, `cube-`) and regenerate
`registry.ts`: ordinary concurrency, regenerate on merge. Widening the
pin reds no sibling.
- **Generated projections** (`spec-changes.json`,
`docs/protocol-upgrade-guide.md`) are regenerated, as in stages 1–3;
only the protocol-17 entries appear in them.
- **What later stages pick up** (whole tree at this head, same
instrument): **571** prose-field sites in the other families, **61**
short `#NN` sites, **7** `surface` sites, and three `cloud#` sites
(`storage-service-list-retired`, `cloud-subpath-retired`,
`cluster-driver-dangling-values-removed`; the fourth,
`data-driver-query-omit-object`, is done here).
- **Noted, not filed — the same rule outside this card's fields.**
Runtime strings outside the migration entries still carry tracker
numbers. One read in passing: `AutomationEngine`'s boot warning for a
pausing node type that never declares `resumeAuthority`
(`packages/services/service-automation/src/engine.ts`, the line that
reads `so the objectstack-ai#3801 resume gate REFUSES every pause it creates`) is
printed to a plugin author, and `resume-authority-declaration.test.ts`
asserts the `3801` and `3823` numbers are in it. That is the
runtime-string rule this card applies, on a producer outside its
surface; it is reported to the seat, not fixed here.

## Line budget

Entry files: **367 changed lines** (+214 / −153) across 33 files,
against the stage-1 ≈400 budget. The whole diff is **871 lines** (+527 /
−344) in 39 files. Of the rest, `registry.ts` is 367, the two
projections are 68 (`spec-changes.json` 44, the upgrade guide 24), the
widened pin is 42, the re-pinned test 2 and the changeset 25.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…its that decided them (stage 3) (objectstack-ai#20533)

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

## What changed

This is stage 3 of the staged sweep. It covers
`packages/spec/src/data/**` and nothing else. It leaves out the files an
open PR or an in-flight claim holds: `data-engine.zod.ts`,
`data-engine.test.ts`, `hook.form.ts`, `analytics*.ts`,
`cube-member-inner-name-retirement.test.ts`, `driver/turso.zod.ts` and
`filter-subtree-provenance.ts`, as the claim names them. It also leaves
out four files that open PRs started editing after the claim:
`driver/turso.test.ts` (PR objectstack-ai#20504, objectstack-ai#20437's, opened 2026-09-28T20:08Z),
`object.form.ts` (PR objectstack-ai#20519, objectstack-ai#20432's, 21:55Z), `object.zod.ts` (PR
objectstack-ai#20521, objectstack-ai#20494's, 22:10Z) and `filter-logic-conformance.ts` (PR objectstack-ai#20523,
objectstack-ai#20444's, 22:39Z). See Acceptance notes. Later stages cover the other
areas, so this PR says `Part of`.

Every comment or docblock site in scope that cited a tracker number
answering 404 has been rewritten in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123). That is **163 sites on 161 lines in 44 files,
covering 40 numbers**. Each rewritten line now cites the commit in
`origin/main` history that decided what the line describes, and it says
in its own words what that commit decided.

No ADR or ruling-record file in `docs/adr/` or `scripts/adr-anchors/`
records the decision behind any of the 43 dead numbers in scope.
ADR-0104 names objectstack-ai#12380 only as a reference, and ADR-0055 states the rule
that objectstack-ai#8772's ruling enforced, not the ruling itself. So every anchor is
a commit: **38 distinct shas**. One number was dropped rather than
anchored: objectstack-ai#17286, a tracking card that recorded an axis as undecided,
under which no commit landed. The sentence keeps its reason in words.

Three comment sites in scope are left on purpose (see Acceptance notes).
Two are the `[objectstack-ai#6259]` marker in `api-derivation.ts:163`, which a test
string reads, and the test comment that names that marker. The third is
`field.zod.ts:370`, whose `objectstack-ai#6111` is objectui's number.

Only comments changed. Every source file keeps its line count (174 lines
out, 174 in, over 45 files), so no line citation into these files moves.
Thirteen of those 174 lines held no dead citation. Eleven are the other
half of a sentence that had to be reflowed or rewritten. One is a table
header (`value-roundtrip-conformance.ts:20`, 「card」 to 「card or commit」,
because its row now holds a commit). One is `api-derivation.ts:164`,
which now carries the `[objectstack-ai#6259]` sentence's commit. No code token moves
(see the guard below). The 41 string-literal sites that carry a dead
number are tokens, so they are left as they were and listed below.

**No citation number is added.** Every tracker number on an added line
was already on the line it replaces. No PR number stands on an added
line.

Two more kinds of file change, both mechanical:
- **One regenerated reference page.** Two of the rewritten docblock
lines (`feed.zod.ts:15`, `:18`) project into
`content/docs/references/data/feed.mdx`. `check:docs` proved that page
stale, and `pnpm --filter @objectstack/spec check:generated --fix`
regenerated only it. The diff is two lines, each the same substitution
as its source line. No page a held file projects into (`analytics.mdx`,
`data-engine.mdx`, `hook.mdx`, `driver-turso.mdx`) moved.
- **A `patch` changeset** for `@objectstack/spec` (see Changeset below).

## Census: `data/`, before and after

**Instrument.** This is the instrument of stages 1 and 2. It sends REST
`GET /repos/objectstack-ai/objectstack/issues/N` without following
redirects, for every distinct number cited in `packages/spec/src/data`.
The population is:
- the citation gate's own exported `CITATION_RE` and
`NON_CITATION_HEADS`, kept when the qualifier is none, `objectstack`,
`objectstack-ai/objectstack`, `framework`, `pre-` or `post-`;
- widened here to the capitalised spellings of those qualifiers (`Pre-`,
`POST-`, `Framework`: 7 sites, one of them dead), which stage 2's
case-sensitive set did not read;
- N of 100 or more, excluding `summon` heads.

Each site is classified by the TypeScript parser as a line comment, a
docblock, a block comment or a string.

**Controls.** The lit controls were `objectstack-ai#16862`, `objectstack-ai#16847` and `objectstack-ai#17698`. The
dead controls were `objectstack-ai#16714`, `objectstack-ai#16715` and `objectstack-ai#16697`. They were probed at
the start, after every 100 numbers and at the end. They read 24 of 24
lit (200) and 24 of 24 dead (404) over 8 checkpoints in both runs.

| reading | tree | numbers probed | 200 | 404 | 301 or other | dead
sites, all of `data/` | in scope | excluded (held files) | in-scope
lines | in-scope files | dead numbers in scope |
|---|---|---|---|---|---|---|---|---|---|---|---|
| before | base `9bf5e67af`, probed 2026-09-28T19:32Z to 19:36Z | 618 |
571 | 47 | 0 | **240** | 207 | 33 | 204 | 47 | 43 |
| after | head `96fd49caa2`, probed 2026-09-28T23:19Z to 23:23Z | 600 |
571 | 29 | 0 | **77** | 44 | 33 | 43 | 16 | 21 |

**Before, in scope, by class.** 92 non-test docblock sites and 13
non-test line comments. 16 test docblock sites and 45 test line
comments. 39 test string sites. 2 non-test string sites.

**After, in scope.** 41 string sites and 3 comment sites remain, all
three deliberate. The head probe found no number newly dead since the
base probe: the same 571 numbers answer 200.

PR objectstack-ai#20226's area table read `data` 239 at an earlier base; this census
reads 240 at `9bf5e67af`. The 33 excluded sites sit in `object.zod.ts`
(15), `analytics.zod.ts` (3), `analytics-strictness-batchd.test.ts` (2),
`analytics-date-range-two-bound-window.test.ts` (1),
`driver/turso.zod.ts` (2), `driver/turso.test.ts` (3),
`filter-subtree-provenance.ts` (3), `filter-logic-conformance.ts` (3)
and `object.form.ts` (1). `data-engine.*` and `hook.form.ts` carry none.

## Per-number table

The counts are in-scope sites and files at the base. `rewritten / left`
gives comment sites rewritten and sites left. Every anchor was read in
its diff or message, not only in its subject: it is the commit that made
the change the line now describes, and its own diff or message names the
number it replaces.

| number | sites / files | rewritten / left | anchor: what it decided |
|---|---|---|---|
| `objectstack-ai#6111` (objectui) | 1/1 | 0/1 | objectui's number, left: see
Acceptance notes |
| `objectstack-ai#6259` | 5/2 | 1/4 | `6968885ef`: retires the producer-less `batch:
'bulk'` row of `DATA_ACTION_TO_API_OPERATION` and the prose calling
`batch` a runtime action. The marker and 2 test strings stay (see
Acceptance notes) |
| `objectstack-ai#6345` | 18/5 | 17/1 | `e2798fab7`: one driver vocabulary; both boot
hosts read the shared table; `mongo` to `mongodb`; turso a builtin; the
fork-1 and fork-2 refusals |
| `objectstack-ai#6571` | 10/2 | 8/2 | `2f3e79351`: `$between` endpoints accept the
ISO/clock strings the platform produces, as a bare string (rider ①) |
| `objectstack-ai#8495` | 9/2 | 6/3 | `4bfe1a539`: refuses `${…}` placeholders in
memory `persistence.path` / `persistence.key` at publish |
| `objectstack-ai#8656` | 1/1 | 0/1 | a test title only |
| `objectstack-ai#8696` | 20/8 | 17/3 | `90a12fb18`, the card's mongodb arm: a bound
secret rides beside an unmodified url as MongoClient `auth`. Its own
pins carry the multi-host form `new URL()` cannot parse and the bound
secret outranking `options.auth` |
| `objectstack-ai#8772` | 3/2 | 3/0 | `75b7c240a`: Direction 2 of the 2026-08-16
maintainer ruling. The builder forces `required: true` on a
`master_detail` under `controlled_by_parent`, and raw parse stays
tolerant. ADR-0055 stays cited beside it |
| `objectstack-ai#8778` | 1/1 | 1/0 | `7901b2dd2`: stamp-only
`tenancy.organizationField`, declared by `sys_api_key` |
| `objectstack-ai#8794` | 2/1 | 2/0 | `1850ebbb0`: corrects the reuse-safety claim on
the filter-subtree mark from the survey's measurement, and routes a
mechanism change to a spec-seat ruling (stage 1's anchor too) |
| `objectstack-ai#8836` | 2/1 | 2/0 | `1850ebbb0`: the same commit, which pins the
invariant (one line carries both numbers) |
| `objectstack-ai#8873` | 6/3 | 6/0 | `096106522`: a bound `credentialsRef` reaches
the postgres server on the DSN branch. Its diff records that `pg` sends
a password only when the server asks |
| `objectstack-ai#8874` | 1/1 | 1/0 | `d70428ae7`: a declared mysql `ssl` reaches
`mysql2` as its own TLS options object, because `mysql2` rejects a bare
boolean |
| `objectstack-ai#8876` | 9/5 | 6/3 | `d634e665b`: exports `urlUserinfoUsername`, and
its diff states the asymmetry that a username is not credential material
|
| `objectstack-ai#9040` | 20/6 | 14/6 | `24206416a`: refuses a credential in the mongo
options passthrough at publish, and redacts the passthrough secret paths
on read |
| `objectstack-ai#9041` | 22/2 | 17/5 | `d491625c1`: refuses a bound `credentialsRef`
with a user-less mongo `config.url`, with the triage's fences |
| `objectstack-ai#10165` | 5/1 | 1/4 | `801296050`: `ttl.onlyWhen` with the canonical
null predicate (maintainer ruling 2026-08-20, option A) |
| `objectstack-ai#10274` | 1/1 | 1/0 | `d1ba685ec`: re-measures the objectui pin
citations and gates the class |
| `objectstack-ai#10329` | 6/2 | 6/0 | `15d58dbf1`: retires the import lookup
transform's steering params (ADR-0049) |
| `objectstack-ai#10347` | 2/1 | 2/0 | `530c1df65`: the Archiver honours a declared
`ttl` (maintainer ruling 2026-08-20) |
| `objectstack-ai#10527` | 2/1 | 1/1 | `5649efbf9`: refuses a diverging retention +
ttl + archive triple at parse time |
| `objectstack-ai#11065` | 7/3 | 5/2 | `20950404c`: a boolean aggregand counts as 1 or
0 in `avg` and `sum`, the first face aligned. No commit message names
the card; this is where the number first entered the tree |
| `objectstack-ai#11195` | 3/1 | 2/1 | `b37231883`: `UserActionsConfigSchema` adopts
`group` / `hideFields` / `rowColor` |
| `objectstack-ai#11215` | 1/1 | 1/0 | `42a117b88`: documents
`NoSQLIndexSchema.unique`'s deliberate scope-vocabulary omission |
| `objectstack-ai#11350` | 1/1 | 1/0 | `ece4dad31`: records the 2026-08-23 maintainer
ruling on entry nameability (stage 1's anchor too) |
| `objectstack-ai#11408` | 2/1 | 1/1 | `f11fc61c5`: declares `editMode` (maintainer
ruling 2026-08-24) |
| `objectstack-ai#11507` | 5/2 | 5/0 | `88b9d749a`: declares `sys_activity.type` an
open, author-extensible vocabulary (maintainer ruling 2026-08-24,
direction 4) |
| `objectstack-ai#11658` | 1/1 | 1/0 | `1a6a19c31`: opens `RecordActivityProps.types`
to author-contributed kinds |
| `objectstack-ai#12380` | 4/2 | 4/0 | `4045b954d`: makes the SQLite `Field.json`
codec injective; its message carries the measured boundary |
| `objectstack-ai#12868` | 1/1 | 0/1 | a test title only. Its comment site sits in
`object.form.ts`, now held by PR objectstack-ai#20519; its deciding commit is
`c459da6bc` (see Acceptance notes) |
| `objectstack-ai#13156` | 1/1 | 1/0 | `fd289be45`: strips tracker ids from
function-declaration-built refusal prose (the card's A half) |
| `objectstack-ai#13644` | 3/2 | 2/1 | `34ce8e7db`: declares
`ctx.referentialFieldClear` on `HookContextSchema` |
| `objectstack-ai#14426` | 2/2 | 1/1 | `40a44b91b`: the undefined-comparand refusal
prescribes the null predicate by its ruled spellings, position-safe |
| `objectstack-ai#14676` | 1/1 | 1/0 | `13c48c2a5`: retires `connector.errorMapping`;
its test states the same assertion-set reasoning |
| `objectstack-ai#16126` | 2/2 | 2/0 | `859ded3ec`: refuses a whitespace-only
`reference` on lookup / master_detail |
| `objectstack-ai#16685` | 4/2 | 4/0 | `ed7243d52`: accepts boolean / toggle for sum /
avg / min / max (decision batch objectstack-ai#80) |
| `objectstack-ai#16867` | 3/2 | 2/1 | `0ee32edef`: `notNull` / `not_null` prescribe
`storage.notNull`, not `required` |
| `objectstack-ai#17014` | 3/2 | 2/1 | `80aef8032`: the one-day date-range presets
prescribe a one-day window, and the table states its end-token
convention |
| `objectstack-ai#17286` | 1/1 | 1/0 | dropped: a tracking card with no landing. The
sentence now says the card is gone and to measure `driver-memory` for
the open set |
| `objectstack-ai#17348` | 1/1 | 1/0 | `51efbf116`: pins the `driver-memory` temporal
text-operator divergence by name in that driver's conformance suite |
| `objectstack-ai#17590` | 1/1 | 1/0 | `e04a0aff2`: `$contains` on a JSON column is a
per-dialect membership test (director-seat ruling 2026-09-12) |
| `objectstack-ai#18012` | 8/3 | 7/1 | `176b03582`: `$between` requires two non-blank
endpoints (decision batch objectstack-ai#146 item 5, letter A) |
| `objectstack-ai#19377` | 6/2 | 6/0 | `a60c913de`: refuses a `{ $field }` reference
as a `$between` endpoint at the runtime filter door |

Every cited sha matches exactly one commit (`git rev-parse
--disambiguate`, count 1), and every one is an ancestor of the base
(`merge-base --is-ancestor`, exit 0). That is 38 distinct shas.

Wordings to check, each true of its commit:
- `datasource.zod.ts:352` names only the card's mongo arm (`90a12fb18`)
for "the defect class … closed", because the paragraph is about mongo.
The card's mysql arm (`72050cc47`) is not cited anywhere in this stage.
- `datasource.zod.ts:354`: 「the triage's, as commit d491625 landed
them」. `d491625c1`'s message lists the fences as "per triage".
- `filter.zod.ts:1021-1025`: the `objectstack-ai#17286` pointer becomes 「was measured
on a tracking card … That card is gone: measure `driver-memory` for the
open set, ⛔ not this text.」 The warning that this paragraph is not the
authority is kept.

## The 41 string sites left as tokens

- **Test titles and test-code strings (39 sites).**
`driver/driver-credential-refusal.test.ts` 14, `object.test.ts` 6,
`datasource-credential-redaction.test.ts` 3,
`driver/driver-placeholder-refusal.test.ts` 3, `filter.test.ts` 3,
`api-derivation.test.ts` 2 (the `split('[objectstack-ai#6259]')` literal and its
message), `field.test.ts` 2, and 1 each in `date-range-presets.test.ts`,
`driver/postgres.test.ts`, `field-rows-option-description.test.ts`,
`filter-comparand-type.test.ts`, `hook.test.ts` and
`object-strictness-batch20.test.ts`.
- **Non-test strings (2 sites).** `aggregation-conformance.ts:398` and
`:407`, the `note` of two exported `AGGREGATION_CASES` rows (`objectstack-ai#11065`,
`objectstack-ai#11151`). They ship as data. Their only readers are driver conformance
suites, which print a `note` as the assertion message when a case fails,
to a driver developer and never to a metadata author. So they are
neither comments nor form D author-shown text. This is the same
disposition stage 1 gave the two `why` strings and stage 2 the
`PROVENANCE_WAIVERS` reason.

No author-shown text in `data/` carries a dead number, so nothing here
is objectstack-ai#20233's form D.

## Mechanical guard: no code token moves

The check compares leaf tokens with comments stripped, base `9bf5e67af`
against head `96fd49caa2`. It uses the TypeScript parser's leaf tokens,
so template literals are scanned in context, and it excludes JSDoc
nodes. It ran over all 45 touched `.ts` files.

- Real run: 140,379 base tokens, **0 files with a token change** (exit
0).
- Comment-insertion control: 0 files changed, as expected (exit 0).
- Positive control (a declaration inserted into `feed.zod.ts`): 1 file
reads DIFFER (exit 1).
- Positive control (one digit changed inside the `split('[objectstack-ai#6259]')`
string in `api-derivation.test.ts`): 1 file reads DIFFER (exit 1).

## Changeset

This change ships bytes, so a `patch` changeset for `@objectstack/spec`
is included. It says only that the provenance comments were re-anchored.

Measured on the built package: 14 of the touched sources are
`src/**/*.zod.ts`, which `files[]` ships verbatim. The rewritten
docblocks also reach `dist`. `88b9d749a`, `e2798fab7` and `24206416a`
each appear in 1 declaration file. `24206416a` appears in 20 bundled
`.js` files and `2f3e79351` in 28. The positive control, a pre-existing
`feed.zod.ts` docblock sentence, appears in `dist/data/index.d.ts`.

## Gates (head `96fd49caa2`)

- **Citation judging pass, run as CI runs it:** `pnpm
check:issue-citations && node scripts/check-issue-citations.mjs` exits
0. The self-test passes 73 cases in 7 batteries. The live run judged 11
citations across 25 files, and all 11 resolve.
- **Doc authoring:** `pnpm check:doc-authoring` exits 0.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at the final head derived 108
families, and all 108 exit 0. `--ran` reports 108 run, 0 NOT MEASURED, 0
unrun, and exits 0. (`check:i18n` was derived at the earlier heads from
`object.form.ts`, and left the set when that file went back to base.)
- At an earlier head, four gates first exited 3 (PREREQUISITE NOT MET)
because the workspace was unbuilt: `check:doc-formula-expressions`,
`check:doc-security-posture`, `check:skill-examples` and
`check:docs-transcript-drift`. At the final head a full `turbo run
build` of `./packages/*` ran first (71 tasks, exit 0, under the shared
verify lock), and every gate exited 0 on its first run.
- `check:generated` was run under the lock against that build: all 15
artifacts are up to date.
- **Build, tests, typecheck and lint:**
  - `pnpm --filter @objectstack/spec build` exits 0.
- `vitest run --maxWorkers=2 src/data` in `packages/spec` at the final
head: 107 files and 3,517 tests pass (1 todo), covering every touched
test file.
- The 12 spec suites outside `src/data` that read `data/` source text
pass at the final head: 12 files, 503 tests. These are
`scripts/{file-description,root-index,skill-map-guards,strictness-ledger}.test.ts`,
`src/api/api-entry-graph.pin.test.ts`,
`src/contracts/scoped-context.test.ts`,
`src/shared/{alias-integrity,evaluated-slot-population,retired-key-migrate-sentence}.test.ts`,
`src/system/constants/platform-object-names.test.ts`,
`src/type-alias-convention.pin.test.ts` and `src/ui/dashboard.test.ts`.
- `pnpm --filter @objectstack/spec typecheck` at the final head exits 0,
including `check:test-typecheck` (53 files, 251 errors, 138 pinned
signatures held).
- Lint, as a proven narrowing at the final head: `eslint
--no-inline-config --format json` over the 45 touched `.ts` files gives
45 files, 0 errors and 0 warnings. All 45 are in eslint's own population
(`isPathIgnored` is false for each). `eslint.config.mjs` never enables
type-aware linting (no `parserOptions.project`, which its own line 328
states), so a comment edit here cannot move the verdict on any untouched
file. The repo-wide `pnpm lint` is CI's run.

## Acceptance notes

- **The `[objectstack-ai#6259]` marker.** `api-derivation.test.ts:236` splits
`DATA_ACTION_TO_API_OPERATION`'s TSDoc on the literal `[objectstack-ai#6259]`, and a
test string may not change here. So the marker line
`api-derivation.ts:163` is byte-identical to the base, and the test
comment at `:232` that names the marker stays too. The sentence's
deciding commit sits on the next line instead: 「(both by commit
6968885)」. A first attempt wrote the commit onto the marker line
itself. The diff-scoped `check-issue-citations` then read the kept
`objectstack-ai#6259` as an added citation and exited 1, so it was moved one line down
(commit `b93f08f8d0`).
- **objectui's `objectstack-ai#6111`.** `field.zod.ts:370` reads 「objectui#6110 +
objectstack-ai#6111 (section)」. The qualifier covers only the first number, so the
citation grammar reads `objectstack-ai#6111` as this repository's (404 here). It is
objectui's number: its introducing commit `f887e5249` writes
`(objectui#6111)` in the same diff, and `objectstack-ai/objectui`
answers REST 200 for objectstack-ai#6111 to this session (and for objectstack-ai#6110 and objectstack-ai#10264).
objectui has no `refs/pull/6111/head`, so it is an issue there, not a
PR. The line is left unchanged. This is objectstack-ai#20330's grammar family, the
same as stage 2's `objectui PR objectstack-ai#10264`, and it is noted there, not
filed.
- **Capitalised qualifiers.** `CITATION_RE` classes `Pre-#N`, `POST-#N`
and `Framework#N` (7 sites in `data/`) as cross-repo and never judges
them. This census read them as this repository's. One was dead and is
rewritten here (`object.test.ts:223`, `POST-objectstack-ai#10347`). This is the same
objectstack-ai#20330 family as stage 1's `pre-` / `post-` finding.
- **Four files held after the claim.** Each joined the exclusions and
went back to the base bytes (hypothesis 2 of the dispatch). Each PR's
hunks were disjoint from this PR's lines, but the dispatch's rule is
file-level.
- `driver/turso.test.ts`: PR objectstack-ai#20504 (objectstack-ai#20437's) opened at
2026-09-28T20:08Z and edits it. Its two comment sites (`:4`, `:58`, both
`objectstack-ai#6345`) went back to blob `7fe99ebf9` in commit `86463ed0a1`. A
no-driver `merge-tree` of that head with PR objectstack-ai#20504's head `5dfa45e9f`
exits 0.
- `object.form.ts`: PR objectstack-ai#20519 (objectstack-ai#20432's) opened at 21:55Z and edits it.
Its one comment site (`:256`, `objectstack-ai#12868`, whose deciding commit is
`c459da6bc`) went back to blob `60713e06f` in commit `3479600dda`.
- `object.zod.ts`: PR objectstack-ai#20521 (objectstack-ai#20494's) opened at 22:10Z and edits one
line at `:2123`. Its 15 comment sites (`objectstack-ai#8772`, `objectstack-ai#10165`, `objectstack-ai#10347`,
`objectstack-ai#10527`, `objectstack-ai#11195`, `objectstack-ai#11408`, `objectstack-ai#13608`) went back to blob `befde04ca` in
commit `96fd49caa2`. Their deciding commits are `75b7c240a`,
`801296050`, `530c1df65`, `5649efbf9`, `b37231883`, `f11fc61c5` and
`fc9ba76a5`, all read for this stage.
- `filter-logic-conformance.ts`: PR objectstack-ai#20523 (objectstack-ai#20444's) opened at 22:39Z.
Its 3 comment sites (`objectstack-ai#13195`) went back to blob `c9b32acba` in the same
commit. Their deciding commit is `9dac1ae01`, with `PR objectstack-ai#13529` as the
link.
- **What stays for later stages.**
- The 33 dead sites in the held files listed above. The later stage can
reuse the deciding commits named for them here.
  - The 41 string sites and the 3 deliberate comment sites above.
- The `data/` numbers that also appear in
`packages/spec/src/migrations/**`. Those are objectstack-ai#20233's form D, or the
migrations stage.
- **The rung.** Several anchored changes also have ADR-0087 entries in
`packages/spec/src/migrations`. Examples are
`cbp-master-detail-required-forced` for objectstack-ai#8772,
`filter-between-blank-endpoint-refused` for objectstack-ai#18012, the `datasource-*`
entries for objectstack-ai#9040, objectstack-ai#9041 and objectstack-ai#8873, and the
`mapping-lookup-params-removed` conversion for objectstack-ai#10329. This PR takes the
commit rung, as stages 1 and 2 did, so it is precedent-consistent. The
D3 id is the more durable in-repo record, if the ruling's first rung is
later read to include those entries.
- **The citation gate's reach.** It defers `packages/**/*.test.ts`, so
20 of the 45 touched `.ts` files never enter its judging population. The
added-minus-removed count over the whole diff covers them: 0 numbers
added.
- **Base.** The branch is 22 commits behind `origin/main` (`1378ec7c0c`,
read at 2026-09-29T00:18Z). Four of those commits touch `data/`, all in
excluded files: objectstack-ai#20475's `hook.form.ts`, objectstack-ai#20487's `data-engine.*`, and,
since this stage excluded them, PR objectstack-ai#20521's `object.zod.ts`
(`9e1689f8e2`) and objectstack-ai#20444's `filter-logic-conformance.ts`
(`fb386074f5`). None touches a file in this diff, and a no-driver
`merge-tree` of the head onto `1378ec7c0c` exits 0. So there was no
merge. The open-PR file lists were re-read at 00:18Z: 11 open PRs, none
touching a file in this diff.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
… sites to the commits that decided them (stage 5) (objectstack-ai#20576)

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

## What changed

This is stage 5 of the staged sweep: the `ui/` area
(`packages/spec/src/ui/**`, 133 files), plus two sites freed since stage
4: `data/filter-subtree-provenance.ts` (PR objectstack-ai#20460 landed without
touching it) and `meta-spelling/manifest-collection-spelling.ts` (the
census hand-over, comment 5882628946 on objectstack-ai#20234).
`ui/view-grouping-query.ts` is excluded because objectstack-ai#20446's claim holds it;
it carries 4 citations and none of them is dead, so the exclusion
removes nothing. Later stages cover the other areas, so this PR says
`Part of`.

Every comment and docblock site in that population that cites a tracker
number answering 404 has been rewritten in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123). That is **87 comment sites**: 84 re-anchored and
3 respelled.
- **84 re-anchored, over 25 numbers.** Each rewritten line now cites the
commit in `origin/main` history that decided what the line describes,
and says in its own words what that commit decided. One line
(`ui/dashboard.zod.ts:686`) quotes ADR-0087 itself; it keeps ADR-0087 as
its citation and paraphrases the amendment heading instead of quoting
its number.
- **3 respelled**, so each number of a sibling pair carries its own
qualifier: `ui/view.zod.ts:3634` now reads `objectui#6110 +
objectui#6111`, and `ui/component.zod.ts:3479` and `:4140` now read
`objectui#8221's PR objectui#8758`. Each second number answers 404 here,
and the sentence attributes it to objectui (objectui REST: `issues/6111`
200, `pulls/8758` 200, merged 2026-09-09).

Only comments changed, plus the one generated reference page they
project into and a patch changeset. Every source file keeps its line
count (90 lines out, 90 in, over 25 files). Three of the 90 lines held
no dead number; each is the other half of a rewritten sentence:
`ui/action.zod.ts:400`, `ui/action-param-carryover.test.ts:13` and
`ui/expression-bindable-text-keys.test.ts:119`. No code token moves (see
the guard below).

**No tracker number is added.** Every tracker number on an added line
was already on the lines it replaces, and no `PR #N` is added.

## Census: before and after

**Instrument.** This is the instrument of stages 1 to 4, rebuilt for
this stage. It sends REST `GET
/repos/objectstack-ai/objectstack/issues/N` without following redirects,
for every distinct number cited in the population. The population is:
- the citation gate's own exported `CITATION_RE` and
`NON_CITATION_HEADS` at the base, kept when the qualifier is none,
`objectstack`, `objectstack-ai/objectstack`, `framework`, `pre-` or
`post-`;
- matched case-insensitively (`Pre-`, `POST-`, `Framework`);
- N of 100 or more, excluding `summon` heads.

A qualifier covers only the number it is joined to. Each site is
classified by the TypeScript parser as a line comment, a docblock, a
block comment or a string.

**Controls.** The lit controls were `objectstack-ai#16862`, `objectstack-ai#16847` and `objectstack-ai#17698`. The
dead controls were `objectstack-ai#16714`, `objectstack-ai#16715` and `objectstack-ai#16697`. They were probed at
the start, after every 100 numbers and at the end: 18 of 18 lit (200)
and 18 of 18 dead (404) over 6 checkpoints in the base run, and 15 of 15
and 15 of 15 over 5 checkpoints in the head run.

| reading | tree | numbers probed | 200 | 404 | 301 or other | dead
sites | lines | files | of which comments | of which strings |
|---|---|---|---|---|---|---|---|---|---|---|
| before | base `487a7846df`, probed 2026-09-29T02:57:01Z to 02:59:36Z |
400 | 372 | 28 | 0 | **111** | 110 | 26 | 90 | 21 |
| after | head `83e39641d0`, probed 2026-09-29T03:15:00Z to 03:18:06Z |
383 | 372 | 11 | 0 | **24** | 23 | 8 | 3 | 21 |

The head probe found no number newly dead since the base probe: the same
372 numbers answer 200. The head was probed at `83e39641d0`; every
census file is byte-identical at the final head.

**Cross-check under the grammar that landed during this stage.** PR
objectstack-ai#20554 (`199002b3e4`) landed the closed qualifier set while this stage
ran, and it reads `objectui PR objectstack-ai#8758` as objectui's number. Re-run with
that gate's own `extractCitations` and `namesThisRepository`, the same
population reads **108** dead sites before and **21** after, all 21 test
strings. The difference is exactly the three `objectui PR objectstack-ai#8758` prose
sites below, which that PR's own header measured as "census deaths here
that are not deaths at all".

**Per file.** Cited sites are every in-repo citation the population
reads, live or dead.

| file | cited sites (base) | dead before | by class | dead after |
|---|---|---|---|---|
| `data/filter-subtree-provenance.ts` | 9 | 3 | 3 docblock | 0 |
| `meta-spelling/manifest-collection-spelling.ts` | 8 | 2 | 2 line
comment | 0 |
| `ui/action-param-carryover.test.ts` | 5 | 3 | 2 line comment, 1 string
| 1 |
| `ui/action.test.ts` | 39 | 3 | 3 line comment | 0 |
| `ui/action.zod.ts` | 90 | 7 | 5 docblock, 2 line comment | 0 |
| `ui/bulk-action.test.ts` | 9 | 4 | 2 line comment, 2 string | 2 |
| `ui/bulk-action.zod.ts` | 9 | 3 | 2 docblock, 1 line comment | 0 |
| `ui/component-element-navigation-17987.test.ts` | 8 | 5 | 1 docblock,
4 string | 4 |
| `ui/component-type-vocabulary.test.ts` | 8 | 2 | 2 docblock | 0 |
| `ui/component-type-vocabulary.ts` | 2 | 1 | 1 docblock | 0 |
| `ui/component.test.ts` | 190 | 22 | 11 line comment, 11 string | 11 |
| `ui/component.zod.ts` | 265 | 20 | 16 docblock, 4 line comment | 0 |
| `ui/dashboard.zod.ts` | 52 | 1 | 1 docblock | 0 |
| `ui/expression-bindable-text-keys.test.ts` | 3 | 2 | 2 line comment |
0 |
| `ui/expression-bindable-text-keys.zod.ts` | 4 | 2 | 2 docblock | 0 |
| `ui/form-select-option.test.ts` | 3 | 1 | 1 docblock | 0 |
| `ui/index.ts` | 15 | 2 | 2 line comment | 0 |
| `ui/interaction-config-retirement.test.ts` | 19 | 1 | 1 docblock | 0 |
| `ui/react-blocks.test.ts` | 9 | 2 | 1 docblock, 1 string | 1 |
| `ui/react-blocks.ts` | 17 | 4 | 2 docblock, 2 line comment | 0 |
| `ui/view-form-features-root.test.ts` | 4 | 1 | 1 line comment | 0 |
| `ui/view-metadata-schema.test.ts` | 31 | 3 | 2 line comment, 1 string
| 1 |
| `ui/view-submit-redirect-url.test.ts` | 8 | 1 | 1 line comment | 0 |
| `ui/view.test.ts` | 136 | 2 | 1 docblock, 1 string | 2 |
| `ui/view.zod.ts` | 331 | 13 | 10 docblock, 3 line comment | 2 |
| `ui/widget-i18n-retirement.test.ts` | 17 | 1 | 1 line comment | 0 |

`ui/` alone went from 106 dead sites in 24 files to 24. The other 107
`ui/` files carry no dead site.

## Per-number table

Anchors are 9-hex commit abbreviations. "Wrote" means the commit's own
diff added the line being rewritten.

| number | comment sites / files | anchor: what it decided |
|---|---|---|
| `objectstack-ai#5970` | 4 / 2, `action.zod.ts:819`, `action.test.ts:268`, `:304`,
`:354` | `97e7e3caa`: `ActionSchema.visible` / `disabled` speak one
condition shape; `visible` gains its boolean arm. Stage 1's anchor for
the same number |
| `objectstack-ai#6276` | 7 / 1, `component.zod.ts:34`, `:165`, `:568`, `:2455`,
`:2546`, `:2554`, `component.test.ts:2126` | `78f0be872`: declares
`element:record_picker`'s flat `sort` / `limit` on the objectstack-ai#5611 rule
(maintainer ruling 2026-08-08, direction A). It wrote `:34`, `:2455` and
the "enumerate by the renderer's read pattern" lesson |
| `objectstack-ai#8794`, `objectstack-ai#8836` | 3 / 1, `filter-subtree-provenance.ts:130`, `:131`,
`:156` | `1850ebbb0`: corrects the reuse-safety claim from the survey
and pins the invariant. It wrote `:131` itself. Stages 1 and 3 gave both
numbers this anchor |
| `objectstack-ai#9933` | 7 / 2, `view.zod.ts:2517`, `:5060`, `:5086`, `:5118`,
`:5513`, `view-metadata-schema.test.ts:398`, `:410` | `d5552ca13`:
admits `columnState` as an explicitly runtime-only view-overlay key,
rejected by name at every authoring door. It wrote "explicitly out of
objectstack-ai#9933's scope" |
| `objectstack-ai#9972` | 3 / 2 (+1 unread), `component.zod.ts:2213`,
`component.test.ts:382`, `:3565`; also `:3612`'s `objectstack-ai#9881/objectstack-ai#9972`, a
slash-joined spelling the grammar does not read | `60e0f900a`: records
the live read point of `page:tabs` `items[].icon` and its accept-pin. It
wrote the `:382` header |
| `objectstack-ai#10194` | 1 / 1, `manifest-collection-spelling.ts:71` (`pre-objectstack-ai#10194`)
| `2306a765c`: `/meta/theme` and `/meta/analytics_cube` stop storing any
JSON as success and validate at the write door. The line now says "the
store-anything branch from before commit 2306a76". Stage 1's anchor |
| `objectstack-ai#10274` | 6 / 2, `component.zod.ts:2304`, `component.test.ts:308`,
`:405`, `:3591`, `:3603`, `:3612` | `d1ba685ec`: re-measures the four
pin citations and gates the class. Its gate header records that the
re-measure found two anchors wrong since they were written, which is why
a refresh re-reads. Stage 3's anchor |
| `objectstack-ai#10485` | 4 / 4, `index.ts:51`,
`interaction-config-retirement.test.ts:116`,
`widget-i18n-retirement.test.ts:112`,
`manifest-collection-spelling.ts:67` | `35ad101bc`: retires the `themes`
carrier and `ThemeSchema` whole (ruled B, 2026-08-21; ADR-0049 stays
cited). Stage 1's anchor |
| `objectstack-ai#11284` | 5 / 2, `react-blocks.ts:39`, `:94`, `:109`, `:295`,
`react-blocks.test.ts:139` | `5383fa670`: the react tier converges on
the metadata-tier vocabulary, deprecate-first. Its changeset heads
"(objectstack-ai#11284, maintainer ruling 2026-08-23)" |
| `objectstack-ai#11350` | 1 / 1, `index.ts:105` | `ece4dad31`: records the maintainer
ruling of 2026-08-23 that a type in an entry's public declarations must
be nameable from that entry. Same wording as stage 1's
`kernel/index.ts:53` |
| `objectstack-ai#11507` | 2 / 2, `component.zod.ts:1465`, `component.test.ts:2726` |
`88b9d749a`: declares `sys_activity.type` an open, author-extensible
vocabulary (maintainer ruling 2026-08-24, direction 4). Stage 3's anchor
|
| `objectstack-ai#11658` | 2 / 2, `component.zod.ts:1464`, `component.test.ts:2725` |
`1a6a19c31`: opens `RecordActivityProps.types` to author-contributed
kinds, executing that ruling. Stage 3's anchor |
| `objectstack-ai#11703` | 3 / 2, `action.zod.ts:399` to `:400`, `:460`,
`action-param-carryover.test.ts:12` to `:13` | `5cb62d88b`:
`clone_permission_set` carries all five copied facets; its params list
had silently dropped three. The lines now name "the silent-drop shape
commit 5cb62d8 fixed" |
| `objectstack-ai#11753` | 5 / 2, `action.zod.ts:66`, `:390`, `:398`, `:409`,
`action-param-carryover.test.ts:1` | `0e4e51b0a`:
`ActionParamSchema.carryOver`, the spec half of the 2026-08-25
maintainer ruling (recommendation A). It wrote every one of these lines,
and its changeset records the `visible: false` measurement `:398` names
|
| `objectstack-ai#12194` | 1 / 1, `view.zod.ts:4838` | `311433f6b`: declares the
metadata item-name grammar (`QUALIFIED_ITEM_NAME_PATTERN` among it) and
refuses it at the publish door. Stage 2's anchor |
| `objectstack-ai#12868` | 5 / 2, `view.zod.ts:2938`, `:2966`, `:3179`, `:7087`,
`form-select-option.test.ts:4` | `c459da6bc`: narrows the per-option
`default` key out of the form-view options vocabulary. Its changeset
records the ruled census `:2966` cites ("measured ZERO occurrences").
Stages 3 and 4's anchor |
| `objectstack-ai#12950` | 3 / 2, `component-type-vocabulary.ts:4`,
`component-type-vocabulary.test.ts:4`, `:101` | `225e7690f`: created
`component-type-vocabulary.ts`; its message records the readiness read
`:101` pins (`global:search` and `global:notifications` stay declared) |
| `objectstack-ai#13156` | 2 / 2, `view-form-features-root.test.ts:70`,
`view-submit-redirect-url.test.ts:110` | `fd289be45`: strips tracker ids
from function-declaration-built refusal prose. It wrote both lines.
Stage 3's wording ("commit fd289be's strip") |
| `objectstack-ai#13670` | 1 / 1, `expression-bindable-text-keys.zod.ts:72` |
`8c6a7fc0b`: records `text.value` as deliberately omitted; its message
states the ruling that `text`'s evaluation channel is `content` alone |
| `objectstack-ai#13672` | 3 / 2, `expression-bindable-text-keys.zod.ts:89`,
`.test.ts:65`, `:118` | `e854a531a`: narrows the `button` row to the
spelling its key reaches, and records `action:button` and `ui:button` as
deliberately out |
| `objectstack-ai#16626` | 1 / 1, `component.test.ts:2336` | `30b099078`: the objectui
pin bump to `53ded82bf7a4` that ships objectui#7754's array-analytics
lowering, the door the family waited on. The association is PR objectstack-ai#16788's
body (it names objectstack-ai#16626 as the card it lands), and `30b099078` is that
PR's merge commit; neither its message nor its diff names objectstack-ai#16626 (the
stage-3 objectstack-ai#11065 precedent) |
| `objectstack-ai#17987` | 9 / 2, `component.zod.ts:10`, `:4014`, `:4062`, `:4153`,
`:4193`, `:5068`, `:5226`, `:5457`,
`component-element-navigation-17987.test.ts:4` | `e233db9db`: declares
element-level `navigation` on `object-kanban` / `object-calendar` and
gives `object-timeline` its `ComponentPropsMap` row, executing the
objectui#8652 ruling (verbatim `B`) |
| `objectstack-ai#18003` | 1 / 1, `dashboard.zod.ts:686` | **ADR-0087**, the rung
above a commit. The line quoted the ADR's own amendment heading, number
included. It now reads "(ADR-0087, its 2026-09-13 amendment, 「the level
half」)": the ADR stays the citation and the fragment it quotes is
verbatim |
| `objectstack-ai#18177` | 5 / 2, `bulk-action.zod.ts:51`, `:169`, `:262`,
`bulk-action.test.ts:61`, `:307` | `adabccf5f`: `BulkActionParamSchema`
is strict and declares `dependsOn`, executing decision batch objectstack-ai#146 item
4, letter A |
| `objectstack-ai#6111` | 1 / 1, `view.zod.ts:3634` | respelled `objectui#6111` (not
re-anchored): it is objectui's number |
| `objectstack-ai#8758` | 2 / 1, `component.zod.ts:3479`, `:4140` | respelled `PR
objectui#8758` (not re-anchored): objectui's PR objectstack-ai#8758, merged 2026-09-09
|

No ADR or ruling-record file in `docs/adr/` or `scripts/adr-anchors/`
records the decision behind any of the 25 re-anchored numbers except
objectstack-ai#18003. ADR-0126 mentions objectstack-ai#11703 and objectstack-ai#11753 only as references
("permission-set precedent"), not as the record of either decision.

**Anchor checks.** Every sha on an added line is one of 23, and none is
on a removed line. At the base `487a7846df`:
- each matches exactly one object (`git rev-parse --disambiguate`, count
1, 23 of 23);
- each is an ancestor (`git merge-base --is-ancestor`, exit 0, 23 of
23); the control leg `e9584681a4` also exits 0, and the repository is
not shallow;
- for 22 of the 23, a grep of the commit's own message or diff finds the
number it replaces (the message for 16; the diff for `0e4e51b0a`,
`5383fa670`, `c459da6bc`, `225e7690f`, `e854a531a`, and for objectstack-ai#8836 in
`1850ebbb0`). `30b099078` is the exception explained in the table.
- Each commit was read for the rule its line states, not only for the
number. In most cases the commit wrote the very line it now anchors.

Wordings to check, each true of its commit:
- `filter-subtree-provenance.ts:130` and `:156` read 「survey commit
1850ebb records」: the survey was the card's, and the commit's message
records its measurement. It is stage 3's wording for the same relation
(「from the survey it records」).
- `component.zod.ts:1465` and `component.test.ts:2726` read 「maintainer
ruling commit 88b9d74 declared」: that commit landed the ruling
(direction 4) as the `sys_activity.type` declaration.
- `manifest-collection-spelling.ts:71` reads 「the store-anything branch
from before commit 2306a76」: before that commit, `PUT
/meta/theme/:name` stored any JSON as success.

## Mechanical guard: no code token moves

The check compares leaf tokens with comments stripped, base `487a7846df`
against the head. It uses the TypeScript parser's leaf tokens from the
head's lockfile, so template literals are scanned in context, and it
excludes JSDoc nodes. It ran over all 25 touched `.ts` files, and every
control mutates the head text in memory only.

- Real run: 101,836 base tokens, **0 files with a token change** (exit
0).
- Comment-insertion control (`ui/index.ts`): 0 files changed (exit 0).
- Positive control (a declaration inserted into `ui/view.zod.ts`): 1
file reads DIFFER at token 19222 (exit 1).
- Positive control (one digit changed in a `component.test.ts` test
title): 1 file reads DIFFER at token 14972 (exit 1).

Line balance holds in every file, 90 out and 90 in over the 25, and
every line count is equal at base and head. Tracker numbers:
added-not-removed is empty in every file. The net-removed numbers are
the 25 in the table, 85 sites: the census's 84 comment sites, plus the
slash-joined `objectstack-ai#9972` at `component.test.ts:3612`.

## Generated page

`check:generated` proved one artifact stale:
`content/docs/references/ui/expression-bindable-text-keys.mdx`, the
projection of `expression-bindable-text-keys.zod.ts`'s module docblock.
`check:generated --fix` regenerated only that page, and a re-run read
`All 15 generated artifacts are up to date`. Its two changed lines are
the `:72` and `:89` substitutions verbatim. No other docblock here
projects into a reference page, and nothing under `skills/**` moved.

## Changeset

This change ships bytes, so a `patch` changeset for `@objectstack/spec`
is included. It says only that the provenance comments were re-anchored.
`Clause-②: no`: no export, key, value or type moves (the guard above).

Measured on the head's built package: 6 touched sources are
`src/**/*.zod.ts`, which `files[]` ships verbatim. The rewritten
comments also reach `dist`:
- `c459da6bc` appears in 32 bundled `.js` files and 2 `.d.ts`;
- `adabccf5f` in 24 `.js` and 2 `.d.ts`; `d5552ca13` and `0e4e51b0a` in
24 `.js` each; `e233db9db` and `78f0be872` in 2 `.js` and 2 `.d.ts`
each;
- the positive control, the pre-existing sentence 「the object-field face
enforces」, appears in 32 files.

## Gates (head `1b885d3c27`)

- **Citation judging pass, run as CI runs it:** `pnpm
check:issue-citations && node scripts/check-issue-citations.mjs`, both
under the grammar PR objectstack-ai#20554 landed, exit 0. The self-test passes 114
cases in 8 batteries. The live, diff-scoped run judged 13 citations
across 11 files: 3 resolve and 10 are declared cross-repo references. It
reads "every citation this change adds resolves".
- **Doc authoring:** `pnpm check:doc-authoring` exits 0. Its 16,759
customer-facing strings across 1,174 spec sources carry no internal
issue id, and the sibling-package prose-id baseline holds with no
growth.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at this head derived 112
families, and all 112 exit 0. `--ran` reports 112 run, 0 NOT MEASURED, 0
unrun. A full `turbo run build` of `./packages/*` at this head ran
first, under the shared verify lock: 71 of 71 tasks, VERDICT
command-exit 0. So no gate met an unbuilt prerequisite.
- **Five roster gates the derivation flags for this diff** (their
rosters sit in `.changeset/` or `packages/`, so their silence proves
nothing): `node scripts/check-changeset-fixed.mjs`, `pnpm --filter
@objectstack/spec run check:spec-changes`, `pnpm check:authz-resolver`,
`pnpm check:error-code-casing` and `pnpm check:filter-alias-parity`. All
exit 0.
- `pnpm --filter @objectstack/spec run check:generated` (derived) reads
`All 15 generated artifacts are up to date`, and `check:docs` reads `226
generated files in sync`.
- **Tests and typecheck, under the lock:**
- `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/ui
src/meta-spelling`: Test Files 98 passed (98), Tests 3452 passed (3452),
VERDICT command-exit 0 (the chain held the lock 142s on a shared box).
- The 16 spec suites outside `src/ui` that read the touched files'
source text or pin their lines: Test Files 16 passed (16), Tests 489
passed (489). They are
`scripts/{export-origins,file-description,root-index,schema-closure,skill-map-guards,strictness-ledger}.test.ts`,
`src/ai/tool-confirmation-prescription-tense.pin.test.ts`,
`src/api/api-entry-graph.pin.test.ts`,
`src/contracts/scoped-context.test.ts`,
`src/data/filter-subtree-provenance.test.ts`,
`src/shared/{alias-integrity,evaluated-slot-population,retired-key-migrate-sentence,union-author-message-pins}.test.ts`,
`src/system/constants/platform-object-names.test.ts` and
`src/type-alias-convention.pin.test.ts`. Three more suites matched the
reader scan and are not run here:
`scripts/build-schemas-check-mode.test.ts` only imports `ViewItemSchema`
(code the guard proves unchanged) and rebuilds schemas in a temp tree;
`scripts/def-key-collisions.test.ts` names `ui/view.zod.ts` only in a
comment; `scripts/published-projection-choke-point.test.ts` matched on
`build-react-blocks-contract.ts`, not a touched file. They are left to
CI.
- `pnpm --filter @objectstack/spec typecheck`: exit 0, including
`check:test-typecheck` (53 files, 251 errors, 138 pinned signatures
held). The same three runs also passed, with the same counts, on the
pre-merge tree.
- **Lint, as a proven narrowing:** `eslint --no-inline-config --format
json` over the 25 touched `.ts` files gives 25 files, 0 errors and 0
warnings. All 25 are in eslint's own population (`isPathIgnored` is
false for each, read through eslint's API). `eslint.config.mjs` never
enables type-aware linting (no `parserOptions.project`, which its own
lines 327 to 328 state), so a comment edit here cannot move the verdict
on any untouched file. The repo-wide `pnpm lint` is CI's run.
- **Merge probe:** a `merge-tree` of the head onto `origin/main`
`f572a7eb3c`, from a bare shared clone with no merge driver registered,
exits 0 (2026-09-29T04:15Z).

## Acceptance notes

- **Base and merge.** The branch forked from `487a7846df`, one commit
past the claim's stamp `6154165484` (PR objectstack-ai#20551, outside the surface).
`origin/main` then moved three commits, and `199002b3e4` (PR objectstack-ai#20554)
changed `scripts/check-issue-citations.mjs`, so the gate derivation read
STALE TREE. `origin/main` `dee9b26f6c` was merged in (`1b885d3c27`): a
clean merge with no driver-routed path and no lockfile change, touching
none of this diff's files. The PR's delta against `dee9b26f6c` is
exactly its 27 files. `origin/main` has since moved one more commit,
`1c761c0d71` (PR objectstack-ai#20565, tests in two other packages), which touches
none of them.
- **One site beyond the hand-over's list.** The hand-over named
`manifest-collection-spelling.ts:71` (`pre-objectstack-ai#10194`). The same comment's
first line, `:67`, cites `objectstack-ai#10485`, which also answers 404, and it is
rewritten too. The claim names this file; the fix is the same defect
class, mechanical in the form stages 1 to 4 fixed, in a file no other
claim holds, under the same gates. Reverting it would be one line.
- **Open PRs, re-read at 2026-09-29T04:22Z:** 8 open PRs, and none
touches any file in this diff. The one that touches `ui/` is objectstack-ai#20570
(objectstack-ai#20446's), on the excluded `view-grouping-query.ts`. The in-flight
`Claim:` comments on the 12 `pm:dispatched` cards were read too: only
objectstack-ai#20446's names a `ui/` file (`view-grouping-query.ts`, excluded above).
- **Hypothesis 2, measured.** In `ui/`, 14 sibling-qualified pairs leave
the second number bare: 13 on one line (`+`, `and`, `,`, `/` or `'s PR`
between them) and `component.zod.ts:3478` to `:3479`, split across a
line break. In 3 of them the second number answers 404 here and the
sentence attributes it to objectui (`objectstack-ai#6111` once, `objectstack-ai#8758` twice); they
are respelled above. In the other 11 the second number answers 200 here,
so it is judged as this repository's and left: `action.zod.ts:1603`,
`component.test.ts:2052`, `:2199`, `:2315`, `component.zod.ts:3276`,
`:3793`, `:4812`, `expression-bindable-text-keys.zod.ts:33`,
`page.test.ts:696`, `react-blocks.ts:256` and `widget.zod.ts:34`. The PR
objectstack-ai#20554 header measured `,` and `and` pairs as naming this repository's
number and `/` pairs as mostly, but not always, the qualifier's. Whether
any `/` pair here names objectui's number is not measured; a 200 here
cannot tell.
- **What stays in this population: 24 dead sites** (21 under the landed
grammar).
- **21 test strings**, left as tokens (vitest `it` / `describe` titles
in 7 test files): `objectstack-ai#6276` ×6, `objectstack-ai#11658` ×3 and `objectstack-ai#11507` ×1 in
`component.test.ts`; `objectstack-ai#9972` in `component.test.ts:411`; `objectstack-ai#17987` ×4 in
`component-element-navigation-17987.test.ts`; `objectstack-ai#18177` ×2 in
`bulk-action.test.ts`; `objectstack-ai#9933` in `view-metadata-schema.test.ts:406`;
`objectstack-ai#11284` in `react-blocks.test.ts:147`; `objectstack-ai#11753` in
`action-param-carryover.test.ts:17`; `objectstack#11195` in
`view.test.ts:3396`. None is a Zod `.describe()` text, an exported
string or a migration-entry field, so no form D site arises here.
- **3 comments that name objectui's live PR objectstack-ai#8758 in prose** (`objectui
PR objectstack-ai#8758`): `view.zod.ts:2354`, `:2579` and `view.test.ts:426`. They are
not pairs, and the landed grammar reads them as objectui's.
- **Left for later stages of objectstack-ai#20234** (the stage-4 landing comment
5882686893's list, unchanged): the migrations area, the `liveness/**`
notes, the `why` strings and the `PROVENANCE_WAIVERS` reason,
`rest-server.zod.ts`, the held `analytics*` and `driver/turso.*` files,
the 2 `AGGREGATION_CASES` note strings, and `data/`'s test strings and
deliberate markers.
- **The same rot outside `packages/spec/src`** is objectstack-ai#20556's, not this
card's. Examples met here: ADR-0087's own amendment heading
(`docs/adr/0087-metadata-protocol-upgrade-contract.md:390`, `:397`)
cites the dead `objectstack-ai#18003`, and ADR-0126 cites `objectstack-ai#11703` and `objectstack-ai#11753`; both
are governed. `packages/spec/scripts/strictness-ledger.test.ts:375`,
`:380` cite `objectstack-ai#9933`, and `check-objectui-pin-citations.ts` cites
`objectstack-ai#10274` and `objectstack-ai#9972`.
- **Unchanged wording.** `react-blocks.test.ts:140` says the 2026-08-23
ruling was "recorded on-card". The card is gone, and the changeset of
`5383fa670` (now cited on `:139`) records the ruling. The line holds no
number, so it is left.
- **The citation gate's reach.** It defers `packages/**/*.test.ts`. The
11 touched non-test files are in its judging population.

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

---------

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 20) (objectstack-ai#21895)

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

Stage 20 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 first name-ordered `ui/` group: the 32 test files
directly under `packages/spec/src/ui/` from
`action-confirm-params-guard.test.ts` to
`component-record-block-field-security.test.ts`. Those files carried 97
messages and 102 tracker ids, citing 65 records. 101 of those ids now
either state what their record decided, in words (form D), or are
dropped where the title already says it. One id stays, because an
assertion in the same file reads it (below). Text only: no assertion,
identifier, test count or code comment changes, and no file is renamed.

## Census at the base (`e085a8c3be`)

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 19 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 base is `e085a8c3be`, stage 19's landing and the claim's base. Both
instruments read **860 messages / 908 ids in 190 files**, the seat's
reading and stage 19's head reading.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `ui/` (this PR: 32 of the 84 files) | 84 | 396 / 419 | 378 / 401 | 18
/ 18 |
| `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 |
| `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 |
| (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** | **190** | **860 / 908** | **804 / 851** | **56 / 57** |

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

| file (under `ui/`) | messages / ids | titles | other |
|:--|--:|--:|--:|
| `action-confirm-params-guard.test.ts` | 4 / 4 | 4 / 4 | 0 |
| `action-description.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `action-dispatch-contract.test.ts` | 4 / 4 | 4 / 4 | 0 |
| `action-doubled-redirect.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `action-newtaburl-pair.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `action-on-success.test.ts` | 3 / 4 | 3 / 4 | 0 |
| `action-param-carryover.test.ts` | 1 / 2 | 1 / 2 | 0 |
| `action-param-default-value.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `action-params.test.ts` | 9 / 9 | 9 / 9 | 0 |
| `action-requires-confirmation-docblock.pin.test.ts` | 5 / 6 | 3 / 4 |
2 / 2 |
| `action-row-update.test.ts` | 5 / 5 | 5 / 5 | 0 |
| `action.test.ts` | 12 / 12 | 12 / 12 | 0 |
| `app-nav-expanded-alias.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `app-nav-target-exclusivity-export.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `app-strictness-batch19.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `app.test.ts` | 9 / 9 | 9 / 9 | 0 |
| `aria-carrier-tombstones.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `assembled-view-artifact-type.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `assembled-views.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `bulk-action.test.ts` | 4 / 4 | 4 / 4 | 0 |
| `calendar-config-allday-prescription-17054.test.ts` | 2 / 2 | 2 / 2 |
0 |
| `chart-aggregate.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `chart.test.ts` | 7 / 9 | 7 / 9 | 0 |
| `component-action-element-rows-20371.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `component-action-row-endpoint-21005.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `component-element-navigation-17987.test.ts` | 4 / 4 | 4 / 4 | 0 |
| `component-form-custom-fields-sections-typed.pin.test.ts` | 1 / 1 | 1
/ 1 | 0 |
| `component-object-grid-default-filters.pin.test.ts` | 1 / 1 | 1 / 1 |
0 |
| `component-object-grid-export-options-members.pin.test.ts` | 1 / 1 | 1
/ 1 | 0 |
| `component-object-grid-pagination-accept-set.pin.test.ts` | 1 / 1 | 1
/ 1 | 0 |
| `component-props-unknown-members.pin.test.ts` | 2 / 2 | 1 / 1 | 1 / 1
|
| `component-record-block-field-security.test.ts` | 5 / 5 | 5 / 5 | 0 |
| **32 files** | **97 / 102** | **94 / 99** | **3 / 3** |

The three "other" strings are
`action-requires-confirmation-docblock.pin.test.ts:168` and `:175` (two
`expect` messages) and
`component-props-unknown-members.pin.test.ts:322`. The claim calls the
third one an `expect` message too; it is the `ruling` value of a ledger
entry. 12 more test files in the same name range carry no id and are not
touched.

- **Controls.** Lit: `ui/component.test.ts`, outside the group, reads 70
ids at the base and at the head. Dark:
`action-requires-confirmation-docblock.pin.test.ts` reads 0 at the head
while 4 of its comment lines still carry a number. Planted in scratch
copies of head files: an id put into an `app-nav-expanded-alias.test.ts`
title reads 1 / 1, and an id put into a `bulk-action.test.ts` comment
reads 0.
- **A wider pattern** (any `#` plus digits) reads the same as the gate
pattern in 29 of the 32 files at the base. The other three differ only
by 16 hex colour literals (`'#0070F3'` in `app.test.ts`, `'#8B5CF6'` in
`bulk-action.test.ts`, the `colors` arrays in `chart.test.ts`), which
this PR does not touch.
- **At the head:** 764 messages / 807 ids in 159 files. The 32 files
read 1 / 1 (the kept `:322`), `ui/` reads 300 / 318, and no other file
moved.

## How the area was chosen

`ui/` has no subdirectory, so it is taken like `data/`, in name-ordered
file groups near the ~100-id bound. Stage 19'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, 764 / 807):
- `ui/` 318 ids in 53 files, about three stages. The next group nearest
100 runs from `component-record-blocks.test.ts` to `dashboard.test.ts`:
7 files, 103 messages / 109 ids (98 titles / 103 ids, 5 other).
`component.test.ts` alone carries 70. Cutting one file earlier gives 88.
That group holds five "other" strings:
`dashboard-chart-structure-refusal.test.ts:94` (two ids) and
`dashboard.test.ts:124` (two ids), which read like placeholders (`objectstack-ai#111`,
`objectstack-ai#222`), and `dashboard.test.ts:205` (objectstack-ai#5022).
- `api/` 201, two stages. `system/` 165, two. The files directly in
`src/`, 120, one.
- The three docblock needles (`ai/build-progress.test.ts` ×2,
`contracts/approval-service.test.ts` ×1), one stage, with an at-tier
review. The id this PR keeps
(`component-props-unknown-members.pin.test.ts:322`) fits there too.

## The three "other" strings: two rewritten, one kept

- **`action-requires-confirmation-docblock.pin.test.ts:168` and `:175`
are not needles.** Each one is the failure message (the second argument
of `expect(value, message)`) of an assertion whose expected value
carries no id: `.toEqual([])` over the docblock's positive `confirmText`
claims, and `.not.toMatch(/confirmText/)` over the classifier's body.
Nothing compares the message text. Both are rewritten and declared to
the text-only tool.
- **`component-props-unknown-members.pin.test.ts:322` is kept.** Its
`ruling: 'decision card objectstack-ai#21704, fork 4, letter B (record 5979239990)'`
is the expected value of the assertion at `:417` in the same file,
`expect(reason.kind === 'opaque' && reason.ruling,
key).toMatch(/objectstack-ai#21704/)`, and the `ruling` slot is typed as naming the
record that holds the member (`:188`). Removing the id turns `:417` red,
and moving that regex would change assertion text, which this stage does
not do. It is not a docblock needle, since it reads a value in its own
file, but it is held the same way. It is reported for the needle stage.

## What each id became

- **36 literals (41 ids)** now state a decision in words.
- **13 literals (13 ids)** get their subject back in words, where the
number stood for a thing, such as "the objectstack-ai#7428 pair rule".
- **47 literals (47 ids)** drop a number the title already explains.

Every cited record was read with its comments through REST: 62 answer
200 and 3 answer 404. Three citations are cross-repo (`objectui#3139`,
`objectui#3382`, `objectstack-ai/objectui#11610`); all three were read
from objectui and answer 200. Where a record answers 404, or answers 200
without the decision, the decision was read from what landed:
- **objectstack-ai#11753** (404): its ruling, recommendation A, is quoted in its
spec-half card objectstack-ai#11992.
- **objectstack-ai#17987** (404): the landing commit `e233db9dbb` ("declare
element-level `navigation` on object-kanban / object-calendar and give
object-timeline its ComponentPropsMap row"), executing objectui#8652's
ruling B.
- **objectstack-ai#18177** (404): the landing commit `adabccf5fb`
("BulkActionParamSchema is strict and declares dependsOn"), decision
batch objectstack-ai#146 item 4, letter A.
- **objectstack-ai#3896** (200): the record is a sharing-rule REST defect, closed with
no comment. The "close-out" the title names is what landed under that
citation: the `action-inert-keys-removed` conversion in
`conversions/registry.ts` ("capability claims nothing enforced") and the
`shortcut` / `bulkEnabled` tombstones in `ui/action.zod.ts`.
- **objectstack-ai#3701** (200, closed with no comment): the convention as
`ui/chart-aggregate.ts` writes it down.

**Stated in words:**

| record(s) | literal (under `ui/`) | now reads | the decision |
|:--|:--|:--|:--|
| objectstack-ai#7278 | `action-confirm-params-guard.test.ts:148` | "… — the
one-dialog shape the confirm question migrated TO" | Maintainer option
1: drop `confirmText` and carry the question as the param dialog's
description, one decision in one dialog. |
| objectstack-ai#7367 | `action-description.test.ts:35` | "ActionSchema.description —
the line the param dialog shows" | `description` joins the action
contract, shaped like `label`, as the param dialog's description line. |
| objectstack-ai#17319 | `action-dispatch-contract.test.ts:43`, `:77` | "… (and still
true: the declared dispatch contract is an authoring key)"; "an action's
dispatch contract speaks `bulkActionDefs`' own vocabulary" | Ruling A:
an action declares its bulk dispatch contract in the bulk def's own
`execution` vocabulary, and a view that wires it the other way is
refused at validate. |
| objectstack-ai#11519 | `action-doubled-redirect.test.ts:24` | "ActionSchema —
doubled post-success navigation is refused, with no precedence field" |
Refuse the doubled channel; no `precedence` field. |
| objectstack-ai#9566, objectstack-ai#9474 | `action-on-success.test.ts:13` |
"ActionSchema.onSuccess — one closed post-success navigation key for api
and script actions" | Ruled together: one closed `onSuccess` key
(`navigate` + `openIn`) for both types. |
| objectstack-ai#4352 | `action-on-success.test.ts:141`; `action.test.ts:1365` | "type
scope — api and script only, refused on any other type, never silently
ignored"; "… the publish gate resolves to it, so a `body` off `script`
is refused at publish" | Outlet 1: the runtime follows the spec, and
contradictory type-scoped metadata errors at publish instead of being
ignored. |
| objectstack-ai#11992, objectstack-ai#11753 | `action-param-carryover.test.ts:17` |
"ActionParamSchema.carryOver — seeded from the row, shown read-only,
submitted verbatim" | Recommendation A on objectstack-ai#11753, executed by objectstack-ai#11992. |
| objectstack-ai#20740 | `action-param-default-value.test.ts:250` | "… refuses a
zone-suffixed `time` value — a time of day is a zone-less wall clock" |
`ClockTimeValueSchema` narrowed to the zone-less wall clock. |
| objectstack-ai#5568 | `action-params.test.ts:139` | "names `_selectedIds` when the
caller sent `selectedIds` — the declared channel for a selection" |
Verified and closed: `params._selectedIds` is the working declared
channel; the reported gap was not one. |
| objectstack-ai#5613 | `action-params.test.ts:294` | "accepts the DUAL-EMIT shape the
runtime emits through the rename window — …" | Contract first, then
`roles` → `positions` with a deprecation window in which the runtime
emits both keys. |
| objectstack-ai#7828, objectstack-ai#13865 |
`action-requires-confirmation-docblock.pin.test.ts:128`, `:150`, `:168`,
`:172`, `:175` | "… docblock names only the declared signals the
classifier reads"; "would flag the retired sentence …"; "… re-seeds the
retired leg — only declared semantics classify" (message); "… still does
not read `confirmText` — only declared semantics classify"; "… reopen
the declared-semantics ruling before the docblock …" (message) | objectstack-ai#7828
option A: `actionLooksDestructive` classifies on declared semantics only
(`mode`, `variant`), never on UI copy. objectstack-ai#13865 retired the docblock
sentence that still named `confirmText`. |
| objectstack-ai#3405 | `action.test.ts:85`, `:141` | "inline lookup reference target
— `reference`, the FieldSchema spelling"; "… at `reference`, the one key
an author writes" | `ActionParamSchema` gains `reference`, named as
`FieldSchema.reference` so the spelling authors already write is the
legal one. |
| objectstack-ai#15811 | `action.test.ts:217` | "… at the SLOT, which needs a `source`
to evaluate, not at the lowering" | Ruling A: every engine-evaluated
expression slot requires a non-blank `source`. |
| objectstack-ai#20323 | `action.test.ts:1116` | "Action ARIA Integration (retired —
no action surface ever read it)" | Retire `action.aria`: no action
surface reads it. |
| objectstack-ai#6888 | `action.test.ts:1513` | "`global_nav` is retired — it rendered
nowhere" | Direction 2: retire `global_nav`, which rendered nowhere in
the running app. |
| objectstack-ai#3896 | `action.test.ts:1612` | "audit close-out — retired
shortcut/bulkEnabled, capability claims nothing enforced" | Read from
what landed (above). |
| objectstack-ai#5016 | `action.test.ts:1650` | "action param option vocabulary —
declared only where a renderer delivers it" | Ruling B, on condition
that it lands with the renderer and after a per-key liveness audit;
`visibleWhen` is declared, `color` / `default` / `icon` / `disabled`
stay refused. |
| objectstack-ai#4001 | `app-strictness-batch19.test.ts:99` | "批 19, unknown keys
refused — the `verify` check …" | Every authorable surface goes strict;
spelled as stage 18 spelled the 批 20 titles. |
| objectstack-ai#5320 | `assembled-views.test.ts:51` | "AssembledViewArtifactSchema —
the declared home for non-container view artifacts" | Fork ruling B: a
declared, portable home for non-container view artifacts. |
| objectstack-ai#4457 | `bulk-action.test.ts:20` | "BulkActionDefSchema — the def
shape is typed, not `z.any()`" | Type the def that was
`z.record(z.any())`. |
| objectstack-ai#17054 | `calendar-config-allday-prescription-17054.test.ts:106` |
"what declaring `allDayField` did NOT open" | Ruling A:
`CalendarConfigSchema` declares `allDayField`. |
| objectstack-ai#3701 | `chart-aggregate.test.ts:45` | "result-column naming
convention — rows keyed by the raw field names" | Read from what landed
(above). |
| objectstack-ai#17751 | `chart.test.ts:271` | "Chart ARIA Integration — retired, no
renderer ever applied it" | Retire `ChartConfigSchema.aria`. |
| objectstack-ai#4001, objectstack-ai#5583 | `chart.test.ts:476` | "批 15 — the two chart sites left
open on a measurement, since CLOSED as strict objects" | objectstack-ai#5583: both
schemas became strict objects. |
| objectstack-ai#5022 | `chart.test.ts:631` | "ChartDrillDownSchema — the honest
subset, every key one ObjectChart reads" | Declare `drillDown` with only
the keys `ObjectChart` reads. |
| `objectui#3382`, objectstack-ai#5435 | `chart.test.ts:721` | "target: 'navigate' is
ACCEPTED — the chart renderer delivers it now" | `ObjectChart` gained
`'navigate'`, so the union gained the member. |
| `objectui#11610` |
`component-form-custom-fields-sections-typed.pin.test.ts:496` | "§5 the
grid widget's eight field-level keys, camelCase since objectui renamed
them" | objectui renamed the eight keys to camelCase with no dual read.
|
| objectstack-ai#19046 | `component-object-grid-pagination-accept-set.pin.test.ts:172`
| "both arms refuse a … pageSize — the view and component arms no longer
disagree" | The grid component arm refuses the page sizes the view arm
refuses. |
| objectstack-ai#18159 | `component-record-block-field-security.test.ts:174` |
"`requiredPermissions` is declared on the three blocks as a capability
set (instruments A and B)" | Ruling A: the key follows the objectstack-ai#19186
ruling, an ADR-0066 capability set. |

**Subject back in words** (13 literals): "objectstack-ai#7428 —" becomes "the
`confirmText` + `params` guard" or "the pair guard"
(`action-confirm-params-guard.test.ts:130`, `:171`), and "the objectstack-ai#7428 pair
rule" becomes "the `confirmText` + `params` pair rule"
(`action-row-update.test.ts:280`); "the surfaces objectstack-ai#7367 …" becomes "the
surfaces the action `description` key …"; two `objectstack-ai#17319 —` prefixes become
"`execution` on an action" and "dispatch-contract default"; "the
pre-existing probes (objectstack-ai#9474)" becomes "the spellings tried before
`onSuccess` existed"; "the bare filter objectstack-ai#14175 declared" becomes "the
bare filter the facade was first typed with"; "objectstack-ai#14092 — boundaries"
becomes "the declarative row update — boundaries"; "objectstack-ai#17631's shape"
becomes "the never-parsing gate it once built"; "§6 objectstack-ai#21464 is closed
out" becomes "§6 the `z.unknown()` member sweep is closed out"; and two
`objectstack-ai#18159 —` prefixes become "the three record blocks" and "the
field-security pair".

**Dropped where already stated** (47 literals, 47 ids). A number goes
only where the title already says its decision. Examples: "ActionSchema
— newTabUrl requires opensInNewTab: true (objectstack-ai#11842)"; "ActionSchema — the
`execute` alias is REMOVED (objectstack-ai#3855)"; the `objectstack-ai#15124 —`, `objectstack-ai#15117 —` and
`objectstack-ai#5779 —` prefixes, each in front of the rule it names; the four
`[objectstack-ai#17987]` prefixes on `component-element-navigation-17987.test.ts`; "—
the shape is closed, so its accept means something (objectstack-ai#18177)"; "retired
fail-open area gates (objectstack-ai#4651)". `(ADR-0049)`, `(ADR-0066 D4)` and
`(ADR-0078)` stay: they cite decision records by number, not tracker
ids.

**No file is renamed.** Four file names carry a number
(`calendar-config-allday-prescription-17054`,
`component-action-element-rows-20371`,
`component-action-row-endpoint-21005`,
`component-element-navigation-17987`); they are not this card's.

## Readers

- **Test-name filters:** none. No tracked script, workflow or package
config passes `-t` / `--testNamePattern`.
- **Snapshots:** none. No `__snapshots__` directory is tracked under
`packages/spec`, and none of the 32 files calls a snapshot matcher.
- **Projects:** one of the 32 files runs in the `repo` project:
`action-requires-confirmation-docblock.pin.test.ts` is listed in
`packages/spec/vitest.repo-tests.json`, because it reads
`packages/runtime/src/action-execution.ts`. The other 31 run in `local`.
- **By substring:** every old literal, plus a window around each id (290
needles), was searched with `git grep` at the base, across the tracked
tree outside its own file. No gate, doc, filter, snapshot or
`scripts/check-*.mjs` self-test reads one. The 11 hits:
- 7 code comments in `ui/component.zod.ts` citing
`objectstack-ai/objectui#11610`, and one in `ai/agent.test.ts:193`
("objectstack-ai#3896 close-out");
  - one release-owned line, `content/docs/releases/v17/17-0.mdx:326`;
- one sibling title in `runtime`
(`action-engine-facade-find-envelope.test.ts:80`, "objectstack-ai#15124 — …").
  None reads a spec test title.
- **The files by name:** a few gates and ledgers name group files by
path, never by title: `scripts/check-parse-guard.mjs` reads a code line
of `app.test.ts`, `packages/spec/test-typecheck-debt.json` keys
`app.test.ts` and `chart.test.ts` on error signatures, and
`vitest.repo-tests.json` lists the docblock pin.

## 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 two lines,
`action-requires-confirmation-docblock.pin.test.ts:168` and `:175`.

- **Result:** 32 of 32 files SAME on all three legs, with the per-file
counts predicted in writing before the run.
- **Totals:** 96 changed string leaves in 96 literals: 94 titles and 2
declared. The diff's `+` and `-` lines are exactly the 96 planned lines,
and every file keeps its line count.
- **Controls (12 of 12 as predicted, 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 message given an id back
VIOLATION; the kept `:322` ruling value edited VIOLATION.
- **Templates and tables:** the one template title
(`component-object-grid-pagination-accept-set.pin.test.ts:172`) changes
only after its `${label}` span. No `.each` title, `%s` / `$name`
placeholder or table row changes.

**Test counts:** the 32 files were run at the base, in a separate base
worktree, and at the head, with `--project local --project repo`. Both
sides read 959 tests in 32 files, all passed, with the same count and
status sequence per file in 32 of 32. 513 full test names change, and no
full name repeats on either side. Each changed name equals the base name
with the planned replacements applied. The comparison script flags one
name: its plan entry spells `’` as the source does, and the printed name
carries the decoded character. With the escape decoded, that name
matches too.

## Changeset: `skip-changeset`

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

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

## Verification (at `fa39bcf62e`)

- `pnpm turbo run build` over all packages: 71 / 71, through the shared
verify lock (`VERDICT command-exit 0`).
- `@objectstack/spec`:
  - `vitest run --project local`: 616 files, 18426 passed, 1 todo.
- `typecheck`: exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 32 touched
files, counted 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 19, 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 32 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 32 configured, 0 ignored. No file sets `parserOptions.project`
or `projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 192 changed lines (+96 /
-96).
- A control-byte scan over the 32 files finds none.

## `main` since the base

Re-fetched just before this PR opened, `origin/main` was two commits
past the base (`67c544ccca`: objectstack-ai#21887, objectstack-ai#21891). Neither touches any of the
32 files, and neither touches `packages/spec`: their 9 files are in
`service-datasource`, `qa/dogfood`, the QA checklist and one changeset.
So `main` was not merged. `git merge-tree` onto `67c544ccca` is clean.
None of the 13 open PRs touches the 32 files.

## Acceptance notes

- **The kept id** (`component-props-unknown-members.pin.test.ts:322`,
read by `:417`) is held for the needle stage, as above.
- **Same-id test titles in this card's later stages** go with those
stages. 30 lines in `packages/spec/src` cite ids this PR handled, for
example `api/api-error-code-type.test.ts:71` ("[objectstack-ai#19920] …"),
`system/job.test.ts:836` ("retired job.id (objectstack-ai#4667)"),
`ui/view-strictness-batch18.test.ts:91` ("objectstack-ai#4001 批 18 — …"),
`ui/view.test.ts:3144` ("(objectstack-ai#3896 close-out)") and
`ui/component.test.ts:3518` ("(objectstack-ai#19514)"). `ui/view.test.ts:3386` cites
`objectui#5435`, a different record.
- **Same-id test titles in other packages** are their lanes' test-string
shares. A search of `describe` / `it` / `test` lines outside
`packages/spec/src` finds 44 lines citing ids this PR handled, in 11
packages: `lint` 16 (6 files), `runtime` 9 (6), `cli` 5 (3),
`plugin-approvals` 3 (2), `spec/scripts` 3 (2), `objectql` 2 (2),
`plugin-security` 2 (2), and one each in `plugin-sharing`, `qa/dogfood`,
`rest` and `service-automation`. Examples:
`runtime/src/action-execution-destructive.test.ts:86` ("(objectstack-ai#7828 Option
A)"), `lint/src/validate-react-page-props.test.ts:1008` ("since
objectui#3382 (objectstack-ai#5435)"),
`cli/test/i18n-extract-action-description.test.ts:26` ("(objectstack-ai#7367)"). Some
of the `objectstack-ai#3896` hits there (`plugin-sharing`, `qa/dogfood`) cite the
sharing-rule record itself.
- **Code comments still carry ids** in these files and their sources,
for example the `[objectstack-ai#13865]` header of the docblock pin, the `objectstack-ai#20323`
comment above `action.test.ts:1116` and the
`objectstack-ai/objectui#11610` comments in `ui/component.zod.ts`.
Comments are not this card's share, and none is touched here.

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

Co-authored-by: Claude <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 59d38855 Deployed Jan 25, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/m

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants