Skip to content

fix(filter): refuse a where on a virtual formula field at both doors, instead of answering 200 with zero rows (#8296) - #8369

Merged
os-zhuang merged 5 commits into
mainfrom
claude/issue-8296-filter-unmaterializable-verdict
Aug 13, 2026
Merged

os-zhuang merged 5 commits into
mainfrom
claude/issue-8296-filter-unmaterializable-verdict

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #8296

formula is the one field type no driver materialises a column for. Three query
axes can name a field; until this PR only two of them said so.

axis verdict for a formula field
SORT 400 INVALID_SORT — ingress (#6994) and engine (#7095)
SEARCH 400 INVALID_FIELD — refused by name (#6674)
FILTER accepted — 200, 0 rows, no error

Dispatched under the standing maintainer ruling of 2026-08-12 (covering #7529 /
#7893 / #8010 / #7912): a declaration the platform cannot honour is refused at
the latest checkpoint that can see the whole picture, naming the offending key
path, and never answered 200.

Premise re-measured on current main

The card's line numbers were stale, so this was re-measured against cb43296ef
by function name, on a real ObjectQL with the real protocol on top — is_open
a formula over the stored status column, subtask_total a summary:

INGRESS where { is_open: true }     ->  0 rows, NO ERROR
INGRESS where { is_open: false }    ->  0 rows, NO ERROR
ENGINE  find    { is_open: true }   ->  [] , NO ERROR
ENGINE  findOne { is_open: true }   ->  null
ENGINE  count   { is_open: true }   ->  0
CONTROL where { status: 'open' }    ->  4 rows
CONTROL where { subtask_total: 5 }  ->  1 row      (`summary` HAS a column)
CONTROL where { no_such_field: 1 }  ->  400 INVALID_FIELD
READ    the formula still hydrates  ->  5 rows, value present

Both directions are wrong and the false one is the dangerous one: the same
predicate against a stored boolean returns every row, so a filter meaning
"not yet done" silently became "no records at all" — a changed row SET under a
200, which no amount of inspecting the response can reveal. The formula READS
correctly in that very same response, so the field is visibly populated and
simultaneously unfilterable.

Both doors, because the engine door is author-reachable

The card left "ingress-only, or the engine door too?" open. Measured: the
engine door is required.
plugin-reports' executeReport forwards a saved
report's filter verbatim —

this.engine.find(report.object_name, { where: q.filter, fields: q.fields, orderBy: q.orderBy, limit })

— which passes through no REST ingress at all, exactly as #7095 measured for
query.orderBy one axis over. An ingress-only fix would have left that half
open.

  • Ingress — assertFilterFieldsExist (packages/metadata-protocol) grows a
    second verdict after unknown. Covers everything reaching findData: the
    list route, POST /data/:object/query, the export route and the RPC
    dispatcher, in every filter spelling (where / filter / filters /
    $filter, the array sugar, and nested $and / $or), naming the caller's own
    wire spelling in param.
  • Engine — assertFilterIsMaterializable
    (packages/objectql/src/filter-comparand-shape.ts) runs inside
    lowerWhereFilterArray, the ONE seam every caller-supplied where passes
    through, so find, findOne, count, aggregate, update and delete all
    answer alike and a new verb cannot miss the gate by omission. It judges the
    CALLER's where only — a middleware-injected RLS / sharing / tenant predicate
    is the platform's own and is never refused.

Both answer 400 INVALID_FIELD with field / fields / object (and param
at ingress), and both prescribe the remedy the sort and search axes already
share, with only the verb changed to name this axis:

Denormalise the value onto 'showcase_task' (a stored field, written when the
source changes) and filter that.

One vocabulary, and the two types that must NOT be caught

Both doors judge the field with the same @objectstack/spec/data predicate the
search axis uses (isVirtualSearchField / SEARCH_VIRTUAL_TYPES) rather than a
locally minted type list, so a gate and the drivers cannot disagree about which
types have a column. summary and autonumber still filter — both get real
stored columns; the set is exactly formula, and both are pinned as controls on
both doors. Reading, projecting and computing a formula field are untouched.

INVALID_FIELD rather than INVALID_FILTER, and no new code minted: this
verdict is about the NAME's type, which is what the ingress door already answers
with INVALID_FIELD on its neighbouring unknown verdict and what the SEARCH
axis answers for this very field class. INVALID_FILTER is objectql's
VALUE-shape envelope (#5869 / #7047) — a different fact. (The triage comment
suggested the INVALID_FILTER family; its operative constraint — do not mint a
new top-level code without checking the ADR-0114 catalog — is honoured. Happy to
flip the constant if the reviewer prefers.)

Blast radius — measured on source AND tests, with one exception

The card's second open question was the migration risk: a filter refusal turns
today's silent-zero surfaces into loud 4xx.

App metadata: clean, and that half of the sweep held. Every formula field
declared in shipped app metadata was enumerated — crm_contact.full_name,
crm_opportunity.expected_revenue / days_to_close, crm_lead.is_closed,
showcase_project.budget_remaining, showcase_field_zoo.f_formula — and each
occurrence checked: they appear only as view COLUMNS, form fields, an FLS
permission entry, translations and a record-level CEL predicate. No example
app, seed, view filter, saved report, flow or dashboard in this repo filters on
a formula field.
(The one where hit, case.is_closed in an analytics unit
test, is a mocked executeAggregate with no registry and no formula field, and
its key is dotted — a shape neither door judges.)

One TEST does filter one, and it is updated in this PR.
examples/app-todo/test/derived-flag-removal.test.ts registers a formula-shaped
object of its own (derived_task, invented by that file — not app metadata, not
in defineStack) to record why #7226 removed two inert flags rather than
deriving them. It pinned exactly the behaviour this PR abolishes: filtering a
formula answering 0 rows with no error. Updated here —

The read/projection half of that file (a formula still COMPUTES both flags
correctly) and its stored-column CONTROL assertions are untouched and still
pass. Nothing under examples/app-todo/src/** or its objectstack.config.ts
was touched — that app declares no formula field at all.

#7226's decision stands, and its reasoning is stronger. A formula field
still materialises no column and still cannot carry a predicate, so the eight
app filters that named those flags still could not have worked; removal in
favour of stored columns is still the only repair. Only the failure mode
changed, from an invisible zero to a named 400 — and that test's own docblock
had named the missing exception as the safe design ("an exception would have
been safe, because someone would have seen it"). This PR supplies it.

The reusable lesson. The first sweep enumerated formula fields declared
outside tests. That reads as "empty in-tree" but excluded exactly the
population that broke. A sweep for a BEHAVIOUR change has to cover test files:
tests are where the old answer is pinned, so a behaviour change lands on the pin
before it lands anywhere else.

Verification

Reverse verification, direction predicted before running: reverting the three
source files to origin/main and rebuilding turned the new pins red and left
every control green — 21 failed / 150 passed, and the failures are exactly the
refusal pins (11 ingress spellings, both message pins, all 6 engine verbs, the
engine message pin, the cross-door agreement pin). The pre-existing unknown
verdict, the stored/summary/autonumber controls, the blast-radius pins and
the registry-less pin stayed green throughout.

Suites, after merging main and rebuilding: objectql 196 files / 3522 tests,
metadata-protocol 79 / 1163, rest 110 / 1817, runtime 150 / 2306 — all
passing. pnpm lint clean, objectql typecheck clean, all 53 check:* gates
from the ESLint job green, plus scripts/check-engine-split-ratio.mjs (surfaced
by re-deriving gates from the actual changed paths, not named in the dispatch
list), and packages/spec check:generated reports all 13 artifacts up to date
after the merge.

After the test/docblock/changeset correction above, the whole workspace test
suite was re-run locally (all 76 packages, partitioned into six balanced shards,
dogfood excluded as in CI), with examples/app-todo at 4 files / 106 tests
passing.


Generated by Claude Code

claude added 3 commits August 13, 2026 08:20
…8296)

The FILTER axis was the last of the three query axes with no
unmaterializable verdict: a `where` on a `formula` field cleared
`assertFilterFieldsExist` because the field IS known, reached a driver
that materialises no column for it, and answered 200 with zero rows in
BOTH directions — while SORT (#6994/#7095) and SEARCH (#6674) refuse the
same field by name.

Ingress: `assertFilterFieldsExist` grows a second verdict, judged by the
same `@objectstack/spec/data` predicate the search axis uses
(`isVirtualSearchField`), so gate and drivers cannot disagree about which
types have a column. `summary`/`autonumber` keep filtering — both have
real stored columns.

Engine: `assertFilterIsMaterializable` closes the door the REST ingress
cannot reach — a saved report forwards `query.filter` straight into
`engine.find` — at `lowerWhereFilterArray`, the one seam every
caller-supplied `where` passes through (find/findOne/count/aggregate/
update/delete).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
@vercel

vercel Bot commented Aug 13, 2026 •

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 13, 2026 10:40am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/objectql.

16 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol, @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol, @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/system-context.mdx (via packages/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)

⛔ 2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)
  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol)

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

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Copy link
Copy Markdown
Contributor Author

PM ruling on the red Test Core (2/3) — this is a patch round, NOT the blast-radius STOP

I read the assertion, not the turbo tail. Failure surface is exactly one test, and every other check on 5e9aa78 is green (ESLint, TypeScript, Build Core, Test Core 1/3 and 3/3, all three Dogfood shards, Temporal Conformance, Check Changeset).

FAIL examples/app-todo/test/derived-flag-removal.test.ts
  > REVERSE — why the derive route was rejected, measured
  > ...and is UNFILTERABLE: 0 rows, no error — which is why deriving was refused

Serialized Error: { status: 400, code: 'INVALID_FIELD', field: 'is_completed',
                    fields: [ 'is_completed' ], object: 'derived_task' }
  ❯ assertFilterIsMaterializable packages/objectql/src/filter-comparand-shape.ts:390:15
  ❯ lowerWhereFilterArray       packages/objectql/src/engine.ts:640:5
  ❯ _ObjectQL.find              packages/objectql/src/engine.ts:7316:13
  ❯ test/derived-flag-removal.test.ts:286:21

Why the STOP condition does not fire

The dispatch bar was: if the blast radius reaches shipped app fixtures or examples, stop — that is a migration question for the maintainer; do not "fix" the fixtures to make your gate pass. The purpose of that rule is to stop a dev quietly mutating an app's declared metadata — an object field, a view filter, seed data — so the platform's new refusal does not fire, hiding a real migration cost by editing the victim.

Measured against that purpose, nothing of the kind is happening here:

  • derived_task is a test-local object literal (const DERIVED = {...}) invented by that test to demonstrate the defect. It is not in TodoApp / defineStack, not an object, view, dashboard, report, flow or seed row.
  • The app's own metadata declares no formula field at all — c11b69905 (fix(example-todo): remove the inert is_completed/is_overdue flags and repair every filter that read them #8295) removed both.
  • The sibling test NOTHING in the whole app stack references either field — keys or values passed, on a recursive walk of the real stack. That is the assertion that would have caught genuine app blast radius, and it is green.

So the only red assertion is one that deliberately constructs a formula-filtering object in order to document that filtering formulas is broken. It goes red because the defect it documents was fixed. Updating it is not fixing a fixture to pass a gate; it is updating a test whose subject matter is the defect.

I am stating this explicitly because it sits on the boundary of a rule I wrote, and I would rather be overruled cheaply than reinterpret my own STOP quietly: @maintainer, if you read "reaches shipped examples" literally rather than by purpose, say so and I will hold #8369 and re-file this as a migration card instead.

This failure is the strongest positive control on the PR

derived-flag-removal.test.ts was written for #7226 by a different card, in a different package, by someone not working on this one. It asserts the pre-#8296 behaviour directly — and its docblock names the missing exception as the safe design that did not exist:

"That asymmetry is the whole argument: an exception would have been safe, because someone would have seen it."

#8296 supplies precisely that exception. An independently-authored test in examples/ going red, through ObjectQL.find on an ordinary app call path with no protocol in the picture, proves the engine door is reachable from real application code — a reverse verification this PR did not have to construct. Please cite it in the PR body.

What must change — and what must not

Must change (three things):

  1. The two it(...) bodies in REVERSE — why the derive route was rejected, measured that filter on is_completed / is_overdue and expect []. Same measurement, new verdict: await expect(...).rejects.toMatchObject({ status: 400, code: 'INVALID_FIELD', field: 'is_completed', object: 'derived_task' }).
  2. The prose that is now factually inverted — the file docblock's "Why removed rather than derived as formulas" section, and the REVERSE describe block's docblock. Both currently say the failure is silent ("0 rows with no error", "returns cleanly rather than throwing", "a wrong answer traded for an invisible one"). examples/app-todo: is_completed and is_overdue are readonly flags that nothing ever maintains — permanently false, and one of them is read by a hook #7226's decision still stands and its reasoning gets stronger, not weaker — a formula still cannot be filtered, so those eight filters still could not have worked; the failure is now a named 400 instead of an invisible zero. Write it that way, cross-referencing The FILTER axis has no unmaterializable verdict: a where on a virtual formula field returns 0 rows silently, while sort and search refuse the same field with a 400 #8296.
  3. The changeset contains a false claim and must be corrected regardless of everything above: "Every shipped example app in this repo was swept: none filters on one, so nothing in-tree needed changing." CI just falsified it. The sweep covered app source and missed test files — which is where current behaviour gets pinned, and therefore where a behaviour change lands first. Restate it accurately and name this test.

Must not change: the first it in that block ("a formula field COMPUTES both flags correctly") — it passed, it only reads, and it is the half proving reads are untouched. Nor the stored-column CONTROLs. Nor anything in examples/app-todo/src/** or objectstack.config.ts. If you find yourself editing app metadata, stop and report — that is the STOP condition, and it has not fired yet.


Generated by Claude Code

…velope (#8296)

`derived-flag-removal.test.ts` registers a test-local formula-shaped object
(`derived_task`, invented by that file) to record why #7226 removed two inert
flags rather than deriving them, and it pinned the exact behaviour #8296
abolishes: filtering a formula answering 0 rows with no error. Its three
filtering assertions now assert the rejection envelope (status 400,
INVALID_FIELD, field, object) instead of an empty array, and the `it` title no
longer claims "0 rows, no error".

#7226's decision is unchanged and its reasoning is stronger: a formula field
still materialises no column and still cannot carry a predicate, so the eight
app filters that named those flags still could not have worked. Only the
failure mode changed, from an invisible zero to a named 400 -- which is the
exception this very docblock had named as the safe design. Both docblocks are
rewritten to state that.

The read/projection half (a formula COMPUTES both flags correctly) and the
stored-column CONTROL assertions are untouched; nothing under
examples/app-todo/src/ or objectstack.config.ts is touched, and that app
declares no formula field at all.

The changeset's blast-radius sentence is corrected in the same commit: the
original sweep covered app source and missed test files, which is where current
behaviour is pinned and therefore where a behaviour change lands first. No app
metadata filters a formula field -- that half held.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH

Copy link
Copy Markdown
Contributor Author

ACCEPT — PM review, domain:metadata seat

Green at b386caff: all 26 checks, each job's own conclusion verified individually rather than by a roll-up. The substance of this review is already on record in the three-question rulings and the red-CI diagnosis, so this receipt is short.

Verified before accepting:

  • ⚠️ The STOP condition was not tripped. Nothing under examples/app-todo/src/** or its objectstack.config.ts was touched; that app declares no formula field at all. The only red assertion was in a test that constructs its own formula-shaped object to document the defect being fixed, and updating it is not fixing a fixture to pass a gate.
  • All four surfaces are now consistent: the test's rejection assertions, both docblocks in derived-flag-removal.test.ts, the changeset, and the PR body's blast-radius section.
  • Path fork check: 6 changed files, all accounted for, none under docs/adr/**, .claude/skills/** or skills/** — so an AI seat may mark this ready and enqueue it.
  • Reverse verification has its direction predicted before running, and the 21 red / 150 green split names exactly which pins move and which controls hold.

Two things this PR did that are worth other seats copying.

First, the sweep correction. The original claim — "no example app, seed, view filter, saved report, flow or dashboard filters on a formula field" — was true and still misleading, because the enumeration covered formula fields declared outside tests and that excluded exactly the population that broke. The rewritten section states the app-metadata half held, names the one test that didn't, and draws the general lesson: a sweep for a behaviour change has to cover test files, because tests are where the old answer is pinned, so a behaviour change lands on the pin before it lands anywhere else. That is now a lane review bar.

Second, the red test was the strongest evidence on the PR rather than an obstacle to it. derived-flag-removal.test.ts was written for #7226 by a different card in a different package, and its docblock had named the missing exception as the safe design — "an exception would have been safe, because someone would have seen it." This PR supplies it. An independently-authored test in examples/ going red through ObjectQL.find, with no protocol in the call path, proves the engine door is reachable from ordinary application code — a reverse verification nobody had to construct.

#7226's decision stands and its reasoning is stronger: a formula still materialises no column and still cannot carry a predicate, so those eight filters still could not have worked. Only the failure mode changed, from an invisible zero to a named 400.

Marking ready and enqueueing. Fixes #8296 closes the card on merge.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 13, 2026 10:58
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit edff010 Aug 13, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8296-filter-unmaterializable-verdict branch August 13, 2026 11:14
hotlong pushed a commit that referenced this pull request Aug 13, 2026
… relay)

Merge commit first, regeneration as its own commit per the sanctioned
sequence; api-surface and export-origins regenerated after a fresh spec
build, spec-changes/upgrade-guide/docs from the merged registry. Both
sides verified present: the #8057 retirement (two [RETIRED] marks, the
prescription const, tombstones + engine refusal) and main's #8296/#8369
virtual-formula where-refusal doors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…on (objectstack-ai#8057, ADR-0049) (objectstack-ai#8399)

* refactor(spec)!: remove never-implemented engine.update() upsert option (objectstack-ai#8057, ADR-0049)

options.upsert was declared on both update-options schemas and allowlisted
by the engine's unknown-option gate while no engine or driver path ever
read it — { upsert: true } was accepted and silently dropped. Removal
route per the finding-grading ruling (2026-08-12): retiredKey() tombstones
on EngineUpdateOptionsSchema and DataEngineUpdateOptionsSchema sharing one
prescription (ENGINE_UPDATE_UPSERT_REMOVED), the key dropped from
ENGINE_UPDATE_OPTION_KEYS with the tombstone quoted from
ENGINE_RETIRED_OPTION_MESSAGES, ADR-0087 registration for both keys plus
the semantic entry engine-update-upsert-retired (no D2 conversion: the
option bag is call-time only, the BatchOptions.validateOnly disposition),
baselines and reference docs regenerated, pins re-pointed to assert the
refusal. Create-if-absent intent is explicit now that the by-id branch
throws RECORD_NOT_FOUND per the objectstack-ai#7867 not-found gate, which stays as-is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2

* chore: add adr-0087 disposition marker to the objectstack-ai#8057 changeset

The Check Changeset gate requires the ADR-0087 question answered in
writing in the changeset body; the D3 registration itself landed in the
previous commit (registered engine-update-upsert-retired).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2

* chore: regenerate spec artifacts from the merged tree (os-regen-merge relay)

Merge commit first, regeneration as its own commit per the sanctioned
sequence; api-surface and export-origins regenerated after a fresh spec
build, spec-changes/upgrade-guide/docs from the merged registry. Both
sides verified present: the objectstack-ai#8057 retirement (two [RETIRED] marks, the
prescription const, tombstones + engine refusal) and main's objectstack-ai#8296/objectstack-ai#8369
virtual-formula where-refusal doors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…ledger (objectstack-ai#8370) (objectstack-ai#8657)

* docs(spec): register the FILTER-axis formula refusal in the ADR-0087 ledger (objectstack-ai#8370)

The refusal shipped in 17.0.0 (objectstack-ai#8296 / PR objectstack-ai#8369) with no semantic entry, so the
migration ledger, spec-changes.json and the generated upgrade guide say nothing
about it. Its SORT-axis twin (objectstack-ai#7095) carries one for the identical shape.

Adds entries/semantic/17.engine-find-formula-filter-refused.ts and regenerates
registry.ts, spec-changes.json and docs/protocol-upgrade-guide.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5tUwGM3LQoqErTfkvRW7W

* merge origin/main (os-regen artifacts taken from main; regeneration follows)

* chore(spec): regenerate migration projections after merging main (step18 + gate repair absorbed)

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…s states each lesson in words, not tracker numbers (stage 1) (objectstack-ai#20285)

Part of objectstack-ai#20233

Clause-②: no

**Stage 1 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`). Those three fields
are text an author is shown, and AGENTS.md's runtime-string rule applies
to them: 「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 the parent
card (comment `5749154545`, 「同意」) sets the shape: the lesson in words,
and no number, dead or alive.

This stage rewrites the busiest entry family, the five `engine-*`
entries: **67 sites → 0**. Each site now says what the cited ruling,
measurement or fix decided. ADR ids stay, because an ADR lives in this
repository. `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. One new pin holds the printed output tracker-free.

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

**Instrument.** 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` and
`acceptanceCriteria` (string literals joined with `+`), then counts `#`
followed by 4 or 5 digits at a word boundary. **Tree:**
`objectstack-ai/objectstack` at `443b2f4fdc` (this branch's base).

**Controls.**
- **Lit:** `17.aggregation-node-distinct-retired.ts` reads 7 sites
(replacement 1, reason 6), the ids a reader sees in its text.
- **Dark (comment lines):** 733 `//` lines in entry files carry a
tracker id, and none is counted. For example,
`18.client-envelope-convergence-analytics-automation.ts` has 5 such
comment lines and counts 0. Those comment sites belong to the sibling
card, and ⛔ this PR does not touch them.
- **Dark (other fields):** `surface` is not in the three-field count.
`17.authoring-schemas-strict-unknown-keys.ts` carries one id in
`surface`, and it is absent from the table. The `surface` field is
counted separately under Acceptance notes.
- **Dark (other tables):** the 413 `retired-keys/` and `retired-defs/`
files have none of the three fields. They count 0, while carrying 687
comment-line hits.

**Totals at `443b2f4fdc`:** **1,016 sites** in **266** semantic entries,
**460** distinct ids, split major 17: 417 and major 18: 599. By field:
replacement 61, reason 901, acceptanceCriteria 54. The line-level upper
bound over all entry files is 1,524 lines in 679 files.

**Why this is not the relayed 2,060.** That figure scanned
`packages/spec/src/migrations/**`, where the generated `registry.ts`
repeats every entry's prose. At the base, `registry.ts` alone holds
2,113 tracker-shaped occurrences. The entry files, which are the source
the generator copies, hold 1,016 sites in these three fields.

**Dead ids.** All 22 distinct ids in the chosen family answer 200, so
none is dead. The other 438 ids are not re-probed at this stage.

**After this PR:** 1,016 → **949** (the `engine-` family 67 → 0, every
other family unchanged).

Families are grouped by the entry-id prefix: the first word of the id,
which is the name family. All three-field sites live in the one
`semantic/` directory, so the directory does not separate them.

| # | family (entry-id prefix) | entries | sites | major 17 / 18 |
replacement / reason / acceptanceCriteria | distinct ids |
|---:|---|---:|---:|---|---|---:|
| 1 | `engine-` **(this PR)** | 5 | 67 | 50 / 17 | 11 / 55 / 1 | 22 |
| 2 | `ui-` | 17 | 65 | 29 / 36 | 6 / 58 / 1 | 42 |
| 3 | `plugin-` | 11 | 46 | 12 / 34 | 1 / 39 / 6 | 31 |
| 4 | `driver-` | 7 | 44 | 19 / 25 | 0 / 44 / 0 | 25 |
| 5 | `kernel-` | 9 | 44 | 0 / 44 | 1 / 41 / 2 | 11 |
| 6 | `system-` | 10 | 44 | 0 / 44 | 0 / 42 / 2 | 6 |
| 7 | `datasource-` | 9 | 43 | 10 / 33 | 3 / 38 / 2 | 14 |
| 8 | `filter-` | 10 | 30 | 8 / 22 | 1 / 28 / 1 | 23 |
| 9 | `field-` | 8 | 28 | 9 / 19 | 5 / 20 / 3 | 20 |
| 10 | `action-` | 5 | 26 | 24 / 2 | 0 / 23 / 3 | 19 |
| 11 | `element-` | 4 | 25 | 0 / 25 | 2 / 19 / 4 | 17 |
| 12 | `data-` | 6 | 24 | 15 / 9 | 0 / 24 / 0 | 15 |
| 13 | `export-` | 3 | 21 | 15 / 6 | 0 / 21 / 0 | 15 |
| 14 | `hook-` | 3 | 17 | 14 / 3 | 0 / 17 / 0 | 12 |
| 15 | `rest-` | 4 | 17 | 2 / 15 | 0 / 17 / 0 | 14 |
| 16 | `api-` | 4 | 16 | 9 / 7 | 0 / 14 / 2 | 11 |
| 17 | `metadata-` | 7 | 16 | 0 / 16 | 1 / 15 / 0 | 11 |
| 18 | `view-` | 7 | 15 | 7 / 8 | 0 / 13 / 2 | 10 |
| 19 | `analytics-` | 5 | 14 | 1 / 13 | 2 / 12 / 0 | 10 |
| 20 | `audit-` | 2 | 14 | 14 / 0 | 2 / 12 / 0 | 8 |
| 21 | `sharing-` | 2 | 14 | 14 / 0 | 1 / 13 / 0 | 11 |
| 22 | `actor-` | 1 | 13 | 13 / 0 | 0 / 13 / 0 | 9 |
| 23 | `http-` | 2 | 13 | 13 / 0 | 0 / 13 / 0 | 11 |
| 24 | `object-` | 4 | 13 | 0 / 13 | 1 / 11 / 1 | 11 |
| 25 | `dataset-` | 3 | 12 | 0 / 12 | 2 / 8 / 2 | 9 |
| 26 | `hot-` | 2 | 12 | 0 / 12 | 0 / 10 / 2 | 5 |
| 27 | `external-` | 1 | 11 | 11 / 0 | 0 / 11 / 0 | 8 |
| 28 | `package-` | 5 | 11 | 3 / 8 | 0 / 10 / 1 | 8 |
| 29 | `query-` | 6 | 11 | 11 / 0 | 4 / 7 / 0 | 6 |
| 30 | `delete-` | 1 | 10 | 10 / 0 | 0 / 10 / 0 | 4 |
| 31 | `stack-` | 2 | 10 | 0 / 10 | 3 / 4 / 3 | 9 |
| 32 | `etl-` | 1 | 9 | 9 / 0 | 1 / 8 / 0 | 7 |
| 33 | `flow-` | 3 | 9 | 1 / 8 | 0 / 9 / 0 | 8 |
| 34 | `storage-` | 1 | 9 | 9 / 0 | 1 / 5 / 3 | 7 |
| 35 | `apimethod-` | 1 | 8 | 8 / 0 | 0 / 7 / 1 | 5 |
| 36 | `dashboard-` | 5 | 8 | 1 / 7 | 0 / 8 / 0 | 8 |
| 37 | `notification-` | 1 | 8 | 8 / 0 | 0 / 7 / 1 | 7 |
| 38 | `record-` | 2 | 8 | 6 / 2 | 0 / 8 / 0 | 4 |
| 39 | `runtime-` | 1 | 8 | 8 / 0 | 0 / 8 / 0 | 6 |
| 40 | `scim-` | 1 | 8 | 0 / 8 | 2 / 5 / 1 | 4 |
| 41 | `aggregation-` | 1 | 7 | 7 / 0 | 1 / 6 / 0 | 6 |
| 42 | `automation-` | 2 | 7 | 0 / 7 | 0 / 5 / 2 | 4 |
| 43 | `cache-` | 1 | 7 | 0 / 7 | 1 / 5 / 1 | 4 |
| 44 | `tenant-` | 2 | 7 | 0 / 7 | 0 / 7 / 0 | 3 |
| 45 | `authoring-` | 1 | 6 | 6 / 0 | 0 / 6 / 0 | 5 |
| 46 | `cli-` | 1 | 6 | 0 / 6 | 0 / 5 / 1 | 5 |
| 47 | `client-` | 4 | 6 | 4 / 2 | 0 / 6 / 0 | 4 |
| 48 | `evaluated-` | 1 | 6 | 0 / 6 | 2 / 3 / 1 | 3 |
| 49 | `identity-` | 1 | 6 | 0 / 6 | 0 / 6 / 0 | 6 |
| 50 | `spec-` | 1 | 6 | 6 / 0 | 0 / 6 / 0 | 6 |
| 51 | `advanced-` | 1 | 5 | 0 / 5 | 1 / 4 / 0 | 5 |
| 52 | `cloud-` | 1 | 5 | 0 / 5 | 2 / 3 / 0 | 5 |
| 53 | `import-` | 1 | 5 | 5 / 0 | 0 / 4 / 1 | 5 |
| 54 | `startup-` | 1 | 5 | 0 / 5 | 0 / 5 / 0 | 5 |
| 55 | `sys-` | 1 | 5 | 0 / 5 | 0 / 3 / 2 | 5 |
| 56 | `tool-` | 1 | 5 | 5 / 0 | 0 / 4 / 1 | 3 |
| 57 | `address-` | 1 | 4 | 0 / 4 | 0 / 4 / 0 | 4 |
| 58 | `declarative-` | 1 | 4 | 4 / 0 | 0 / 4 / 0 | 3 |
| 59 | `packages-` | 1 | 4 | 0 / 4 | 0 / 4 / 0 | 3 |
| 60 | `platform-` | 1 | 4 | 0 / 4 | 0 / 4 / 0 | 3 |
| 61 | `session-` | 2 | 4 | 0 / 4 | 0 / 4 / 0 | 3 |
| 62 | `sort-` | 1 | 4 | 4 / 0 | 0 / 4 / 0 | 2 |
| 63 | `strategy-` | 1 | 4 | 0 / 4 | 1 / 2 / 1 | 3 |
| 64 | `admin-` | 2 | 3 | 0 / 3 | 0 / 3 / 0 | 3 |
| 65 | `ai-` | 1 | 3 | 0 / 3 | 0 / 3 / 0 | 2 |
| 66 | `assembled-` | 1 | 3 | 0 / 3 | 0 / 3 / 0 | 3 |
| 67 | `auth-` | 1 | 3 | 3 / 0 | 0 / 3 / 0 | 2 |
| 68 | `change-` | 2 | 3 | 0 / 3 | 0 / 3 / 0 | 2 |
| 69 | `device-` | 1 | 3 | 0 / 3 | 0 / 3 / 0 | 2 |
| 70 | `epoch-` | 1 | 3 | 0 / 3 | 0 / 3 / 0 | 2 |
| 71 | `incident-` | 2 | 3 | 0 / 3 | 0 / 3 / 0 | 2 |
| 72 | `logging-` | 1 | 3 | 0 / 3 | 0 / 3 / 0 | 2 |
| 73 | `memory-` | 1 | 3 | 0 / 3 | 0 / 3 / 0 | 2 |
| 74 | `rls-` | 2 | 3 | 0 / 3 | 0 / 3 / 0 | 2 |
| 75 | `send-` | 1 | 3 | 0 / 3 | 1 / 2 / 0 | 2 |
| 76 | `standard-` | 2 | 3 | 0 / 3 | 0 / 3 / 0 | 2 |
| 77 | `training-` | 2 | 3 | 0 / 3 | 0 / 3 / 0 | 2 |
| 78 | `turso-` | 1 | 3 | 0 / 3 | 0 / 3 / 0 | 3 |
| 79 | `websocket-` | 1 | 3 | 0 / 3 | 0 / 3 / 0 | 2 |
| 80 | `autonumber-` | 1 | 2 | 0 / 2 | 0 / 2 / 0 | 2 |
| 81 | `batch-` | 2 | 2 | 2 / 0 | 0 / 2 / 0 | 2 |
| 82 | `cbp-` | 1 | 2 | 0 / 2 | 1 / 1 / 0 | 1 |
| 83 | `schedule-` | 1 | 2 | 0 / 2 | 1 / 1 / 0 | 2 |
| 84 | `structured-` | 1 | 2 | 0 / 2 | 0 / 2 / 0 | 2 |
| 85 | `time-` | 1 | 2 | 0 / 2 | 0 / 2 / 0 | 2 |
| 86 | `workflow-` | 1 | 2 | 2 / 0 | 0 / 2 / 0 | 2 |
| 87 | `approval-` | 1 | 1 | 1 / 0 | 0 / 1 / 0 | 1 |
| 88 | `audience-` | 1 | 1 | 0 / 1 | 0 / 1 / 0 | 1 |
| 89 | `branded-` | 1 | 1 | 0 / 1 | 0 / 1 / 0 | 1 |
| 90 | `cluster-` | 1 | 1 | 0 / 1 | 0 / 1 / 0 | 1 |
| 91 | `connector-` | 2 | 1 | 1 / 0 | 0 / 1 / 0 | 1 |
| 92 | `enhanced-` | 1 | 1 | 1 / 0 | 0 / 1 / 0 | 1 |
| 93 | `esignature-` | 1 | 1 | 0 / 1 | 0 / 1 / 0 | 1 |
| 94 | `event-` | 1 | 1 | 0 / 1 | 0 / 1 / 0 | 1 |
| 95 | `job-` | 1 | 1 | 1 / 0 | 0 / 1 / 0 | 1 |
| 96 | `position-` | 1 | 1 | 1 / 0 | 0 / 1 / 0 | 1 |
| 97 | `ups-` | 1 | 1 | 1 / 0 | 0 / 1 / 0 | 1 |
| | zero-site families: `cel-` (2), `cube-` (1), `execution-` (1),
`inline-` (1), `list-` (1), `manifest-` (2), `observability-` (1),
`page-` (1), `saved-` (1), `screen-` (1), `translation-` (1), `wait-`
(1) | 14 | 0 | | | 0 |
| | **total** | **266** | **1016** | 417 / 599 | 61 / 901 / 54 | 460 |

## Stage 1 = the `engine-` family

It is the busiest family: 67 sites in 5 entries, 22 distinct ids. It
also fits a reviewable stage, at **112 changed lines in entry files**
(+64 / −48) against the ≈400 budget, with generated `registry.ts`
excluded. The five entries are the data engine's query and write option
refusals:

| entry | sites (replacement / reason / acceptanceCriteria) |
|---|---|
| `17.engine-dotted-projection-refused` | 17 (3 / 14 / 0) |
| `17.engine-find-formula-filter-refused` | 17 (4 / 13 / 0) |
| `17.engine-find-formula-order-by-refused` | 14 (2 / 12 / 0) |
| `17.engine-update-upsert-retired` | 2 (0 / 1 / 1) |
| `18.engine-dotted-filter-refused` | 17 (2 / 15 / 0) |

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

I read each cited issue or PR myself: the body, and the comments where a
ruling or a measurement lives. The ids are in code spans so that this
body does not post 22 cross-references. There are no unresolved sites:
every citation's decision was established from what it says.

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `objectstack-ai#3821` | The SQL driver's `find` must not turn an unknown column into
"no rows": it retries without the projection or ORDER BY. That is the
unknown-column recovery ladder. The GitHub title names the sharing-rule
recipient picker, which is where the defect surfaced. The driver's
`sql-driver-unknown-column-recovery.test.ts` header ties the two
together. | "the driver's unknown-column recovery ladder — there so an
unknown column never reads as "no rows"", and later "the recovery
ladder" / "the unknown-column backstop" |
| `objectstack-ai#4226` | At the REST list route, a `sort` naming a nonexistent field
answers `400 INVALID_SORT` instead of being dropped. | "The SORT axis is
closed at the REST ingress for an unknown field, …" |
| `objectstack-ai#4256` | A dotted `sort` path is refused at the ingress, rather than
silently unsorted. | "… a dotted path …" / "SORT refuses it" / "the SORT
axis prescribes when it refuses the dotted spelling" |
| `objectstack-ai#5918` | Maintainer ruling, 2026-08-07: the analytics ad-hoc path
refuses a relation-traversing dotted measure loudly, with a 400 naming
the caller's spelling. It had silently aggregated a base-table column. |
"the line analytics already takes for a relation-traversing dotted
measure, refused with a 400 naming the caller's spelling rather than
computed against the wrong column" |
| `objectstack-ai#6674` | A `formula` field declared in `searchableFields` is refused
loudly, never admitted as search coverage. | "SEARCH refuses it by name"
/ "the SEARCH axis prescribes when it refuses a formula search field" |
| `objectstack-ai#6924` | The dotted-sort hint prescribes a STORED field, not a
"formula or rollup" field. A formula has no column. | "the ingress sort
hint's stored-field prescription" / "the sort axis when it refuses a
dotted sort" |
| `objectstack-ai#6994` | A non-dotted `orderBy` on a `formula` field is refused at
the ingress (`400 INVALID_SORT`). | "… and a `formula` field alike" /
"at the REST ingress" |
| `objectstack-ai#7095` | Maintainer ruling, 2026-08-10: the engine refuses a formula
ORDER BY it cannot apply, at the public boundary. The internal tolerance
survives only on a measured caller, and none was found. Its PR
registered the change as a semantic entry (disposition `registered`)
although no stored row is rewritten. | "Ruled by the maintainer on
2026-08-10: …" / "The sweep of every in-tree `orderBy` …" / "the
formula-sort refusal (`engine-find-formula-order-by-refused`)" /
"Registered on the ruling inherited from the SORT axis — its engine
refusal was registered in this ledger although no stored row needs
rewriting …" |
| `objectstack-ai#7532` | The REST ingress refuses a dotted projection with `400
INVALID_FIELD` instead of widening the response to every field.
Resolving the path was explicitly not authorised. | "The REST ingress
closed the PROJECTION axis' dotted leg first, refusing a dotted entry
instead of widening the response to every field" |
| `objectstack-ai#7534` | An unknown field inside `where` / `$filter` / a filter AST
is refused with `400 INVALID_FIELD`. | "cleared the unknown-name check
(which refuses a key naming no field of the object)" |
| `objectstack-ai#7537` | A nested `expand` projection that omitted `id` was a silent
no-op. The engine now keeps the join key in the sub-read and strips it
from the output. | "the same silent no-op a nested projection omitting
the related `id` produced before the engine began keeping that join key
itself" |
| `objectstack-ai#7588` | The PR that implemented `objectstack-ai#7532`. | folded into the `objectstack-ai#7532`
sentence |
| `objectstack-ai#7589` | Maintainer ruling, 2026-08-12 (Option B): the engine refuses
a dotted projection at its own head-only filter. The
unknown-plain-column tolerance is kept, and a driver-side carve-out
waits for measured need. The flow `get_record` chain was measured end to
end. | "Ruled by the maintainer on 2026-08-12: …" / "that caller set was
measured, not assumed:" / "PROJECTION refuses it at both doors" |
| `objectstack-ai#7601` | A measurement found that no populate step exists. The spec
and docs stop prescribing a dotted `fields` path. | "a measurement found
that NO populate step exists" |
| `objectstack-ai#7617` | The PR that made the spec and docs stop prescribing the
dotted path. | "once the spec and docs stopped prescribing a dotted
`fields` path" |
| `objectstack-ai#7867` | A by-id update whose id names no row throws
`RECORD_NOT_FOUND`: the engine's not-found gate. | "the engine's
not-found gate — a by-id update whose id names no row throws
RECORD_NOT_FOUND" |
| `objectstack-ai#8057` | `options.upsert` is removed (ADR-0049). One prescription
constant is quoted by the engine gate and by both schemas. | "the engine
gate and both schemas quote one removal prescription" |
| `objectstack-ai#8296` | The FILTER axis gets its formula verdict at both doors: `400
INVALID_FIELD`, judged by the spec's own virtual-field predicate. |
"Both doors now refuse it with `400 INVALID_FIELD`" / "The formula
verdict deliberately skipped dotted keys" / "the one-source move the
formula verdict made with `isVirtualSearchField`" |
| `objectstack-ai#8369` | The PR that implemented `objectstack-ai#8296`. | folded into the `objectstack-ai#8296`
sentence |
| `objectstack-ai#8370` | Triage, 2026-08-13: register the FILTER refusal as a
semantic entry, inheriting the SORT-axis answer. | "re-affirmed for this
axis at triage on 2026-08-13" |
| `objectstack-ai#8371` | Maintainer ruling (delegated), 2026-08-15: refuse a dotted
filter key whose head is a relation, a formula or a plain scalar, at
both doors. A structured/JSON head is left unjudged. The ruling was
preceded by a three-driver measurement. | "Measured across all THREE
drivers before ruling" / "per the maintainer's ruling" |
| `objectstack-ai#8790` | Maintainer ruling, 2026-08-15: an unresolvable WHERE column
is refused by both the list and the count half, with `INVALID_FILTER` /
400. It has its own entry. | "a divergence with its own entry,
`driver-sql-unresolvable-where-column-refused`, that now refuses it on
both" |

The only call-shaped token the rewrite touches is `find()`: one is
removed and one is added, so textual call-spelling ratchets that read
`registry.ts` count the same.

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

The test spawns the real CLI (`os migrate meta --from 16 --to 18`) over
a stack authoring the shapes the family is about: a lookup and a virtual
`formula` field. It locates each `engine-*` block **verbatim** in the
printed output, then asserts that the printed block carries no `#` plus
4 or 5 digits. Three things keep it from passing vacuously:
- The family is derived from the registry by id prefix, with the five
rewritten ids as its floor.
- Presence in stdout is asserted before cleanliness.
- The detector is exercised on both sides first: it is lit on 4 and 5
digits, and dark on 3 digits, 6 digits and `ADR-0112`.

The file follows the existing `migrate-meta-default-range.test.ts`
pattern: queue tier by name (not `.e2e`, which is nightly-only),
`integration` project by behaviour.

## Ablation — the pin can fail

The ablation ran from committed state, HEAD `1fa8251067`, with
`scripts/ablation-replace.mjs` in wrap mode and
`scripts/ablation-dist-preflight.mjs` gating each leg.
- **Attempts 1 and 2 are VOID and are not readings.**
- In attempt 1, the spec build never got the verify lock (queue-timeout,
exit 99). The preflight reported the marker ABSENT from `dist/`, so the
green pin run after it measured the pre-mutation build.
- Attempt 2 mutated the entry file, and the build ran. But the published
bundle is built from the generated `registry.ts`, not from the entry
files, so the preflight again reported ABSENT and the pin was not run.
- **Attempt 3, the reading:**
- **Mutation.** The mutation went into `registry.ts`, the same line the
generator emits for the entry: anchor `quote one removal prescription`
becomes `quote the objectstack-ai#8057 removal prescription`. Anchor went 1 → 0 and
marker 0 → 1, and the blob changed from `b956ae88` to `cef8c6e2`.
- **Mutate leg.** The spec build ran (command-exit 0). The preflight
found the marker in 4 built files. The pin went **red**, 1 failed | 2
passed: `engine-update-upsert-retired: the printed guidance cites a
tracker id: expected 'objectstack-ai#8057' to be undefined`.
- **Restore.** The tool proved the restore: blob `b956ae88` == HEAD, and
`git diff HEAD` is empty.
- **Restore leg.** The spec build was rerun (command-exit 0). `--absent`
found the marker in none of 222 built files, and the tree was clean. The
pin went **green**, 3 passed.

## Verification (all at HEAD `1fa8251067`)

- **Pin:** `pnpm --filter @objectstack/cli exec vitest run --project
integration --maxWorkers=2 test/migrate-meta-engine-guidance.test.ts`,
with `Tests 3 passed (3)`. That is the restore-leg run, after the spec
rebuild.
- **Spec migrations:** `pnpm --filter @objectstack/spec exec vitest run
--project local --maxWorkers=2 src/migrations`, with `Test Files 3
passed (3)` and `Tests 149 passed (149)`.
- **Typecheck:**
  - `pnpm --filter @objectstack/spec typecheck` exits 0.
- `pnpm --filter @objectstack/cli typecheck` exits 0. The test layer
holds its recorded 28 errors in 3 files, unchanged. `tsc --listFiles -p
packages/cli/tsconfig.test.json` puts the new file in the program with 0
errors in it.
- **CLI tiers:** `test/vitest-tiers-partition.test.ts` passes, 22 tests.
The rest of the CLI `unit` / `integration` suites are declared to CI:
the diff touches no CLI source, and adds only this one integration-tier
file.
- **Build:** the build closure is `pnpm exec turbo run build
--filter="@objectstack/cli^..." --concurrency=2`, with 55/55 tasks.
- **Gate families:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derives 88 families from this
change set. `--ran` over the recorded exit codes reads **88 derived, 87
run, 1 NOT MEASURED, 0 unrun**.
- The 87 exit 0. They include `check:migration-registry`,
`check:spec-changes`, `check:upgrade-guide`, `check:generated` (15
artifacts up to date), `check:doc-authoring`, `check:issue-citations`,
`check:nul-bytes`, `check:adr-0087-registration` and
`check:changeset-no-major`.
- **NOT MEASURED:** `check:dual-build-cjs-loads`, `PREREQUISITE NOT MET`
(exit 3). It reads every package's `dist/`, and this worktree built only
the CLI's dependency closure. CI builds the whole repo.
- **Lint (a proven narrowing, not the repo-wide run, which is CI's):**
`eslint --no-inline-config --format json` over the 7 changed `.ts` files
reports 7 files, 0 errors and 0 warnings.
- The population is read from `eslint.config.mjs`, which lints
`**/*.{ts,tsx,mts,cts,js,…}` minus `NEVER_LINTED`, and all 7 files are
in it.
- Invariance: the config never enables type-aware linting (its own
header says so: no `parserOptions.project`, no typed rules). A text-only
edit therefore cannot move the verdict on any file it does not touch.
- **Mergeability:** the branch is 7 commits behind `origin/main`
`89f87f2344`. `git merge-tree --write-tree HEAD origin/main` exits 0,
and `registry.ts` auto-merges. Main's one new semantic entry is not an
`engine-*` entry.

## Acceptance notes

- **`surface` is printed too, and it is outside this card's three
fields.** The block header `⚠ [protocol N] surface → …` shows the
`surface` text to the author. By the same instrument, 9 tracker-id sites
sit in the `surface` of 6 entries:
`authoring-schemas-strict-unknown-keys` (1),
`dataset-measure-aggregate-field-type-refused` (2),
`evaluated-expression-slots-source-required` (2),
`flow-edge-condition-evaluated-slot-source-required` (2),
`plugin-manifest-contributes-routes-retired` (1) and
`ui-react-list-view-binding-aliases-retired` (1). None is in the
`engine-` family, and the pin already holds the whole printed block,
`surface` included. This is noted for the card's later stages, not
filed.
- The pin selects its family by id prefix. A later stage can widen the
same file to its own family, rather than add a second spawn.
- **Generated projections regenerated beyond the claim's listed
surface:** `packages/spec/spec-changes.json` and
`docs/protocol-upgrade-guide.md` carry the same entry text, and their
`--check` gates are red until regenerated. Both are generator output
only.

## Line budget

Entry files: **112 changed lines** (+64 / −48) across 5 files, against
≈400. The whole diff is 471 lines (+346 / −125) in 10 files. Of the
rest, `registry.ts` is 114, the two projections are 56, the pin is 169
and the changeset is 20.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants