…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>
Fixes #8296
formulais the one field type no driver materialises a column for. Three queryaxes can name a field; until this PR only two of them said so.
formulafield400 INVALID_SORT— ingress (#6994) and engine (#7095)400 INVALID_FIELD— refused by name (#6674)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
mainThe card's line numbers were stale, so this was re-measured against
cb43296efby function name, on a real
ObjectQLwith the real protocol on top —is_opena
formulaover the storedstatuscolumn,subtask_totalasummary:Both directions are wrong and the
falseone is the dangerous one: the samepredicate 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'executeReportforwards a savedreport's filter verbatim —
— which passes through no REST ingress at all, exactly as #7095 measured for
query.orderByone axis over. An ingress-only fix would have left that halfopen.
assertFilterFieldsExist(packages/metadata-protocol) grows asecond verdict after
unknown. Covers everything reachingfindData: thelist route,
POST /data/:object/query, the export route and the RPCdispatcher, in every filter spelling (
where/filter/filters/$filter, the array sugar, and nested$and/$or), naming the caller's ownwire spelling in
param.assertFilterIsMaterializable(
packages/objectql/src/filter-comparand-shape.ts) runs insidelowerWhereFilterArray, the ONE seam every caller-suppliedwherepassesthrough, so
find,findOne,count,aggregate,updateanddeleteallanswer alike and a new verb cannot miss the gate by omission. It judges the
CALLER's
whereonly — a middleware-injected RLS / sharing / tenant predicateis the platform's own and is never refused.
Both answer
400 INVALID_FIELDwithfield/fields/object(andparamat ingress), and both prescribe the remedy the sort and search axes already
share, with only the verb changed to name this axis:
One vocabulary, and the two types that must NOT be caught
Both doors judge the field with the same
@objectstack/spec/datapredicate thesearch axis uses (
isVirtualSearchField/SEARCH_VIRTUAL_TYPES) rather than alocally minted type list, so a gate and the drivers cannot disagree about which
types have a column.
summaryandautonumberstill filter — both get realstored columns; the set is exactly
formula, and both are pinned as controls onboth doors. Reading, projecting and computing a formula field are untouched.
INVALID_FIELDrather thanINVALID_FILTER, and no new code minted: thisverdict is about the NAME's type, which is what the ingress door already answers
with
INVALID_FIELDon its neighbouringunknownverdict and what the SEARCHaxis answers for this very field class.
INVALID_FILTERis objectql'sVALUE-shape envelope (#5869 / #7047) — a different fact. (The triage comment
suggested the
INVALID_FILTERfamily; its operative constraint — do not mint anew 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
formulafielddeclared 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 eachoccurrence 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
wherehit,case.is_closedin an analytics unittest, is a mocked
executeAggregatewith no registry and no formula field, andits 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.tsregisters a formula-shapedobject of its own (
derived_task, invented by that file — not app metadata, notin
defineStack) to record why #7226 removed two inert flags rather thanderiving them. It pinned exactly the behaviour this PR abolishes: filtering a
formula answering 0 rows with no error. Updated here —
(
status: 400,code: 'INVALID_FIELD',field,object) instead of anempty array;
ittitle no longer claims "0 rows, no error";whereon a virtual formula field returns 0 rows silently, while sort and search refuse the same field with a 400 #8296 truth.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 itsobjectstack.config.tswas 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/mainand rebuilding turned the new pins red and leftevery 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
unknownverdict, the stored/
summary/autonumbercontrols, the blast-radius pins andthe registry-less pin stayed green throughout.
Suites, after merging
mainand rebuilding:objectql196 files / 3522 tests,metadata-protocol79 / 1163,rest110 / 1817,runtime150 / 2306 — allpassing.
pnpm lintclean,objectqltypecheck clean, all 53check:*gatesfrom the ESLint job green, plus
scripts/check-engine-split-ratio.mjs(surfacedby re-deriving gates from the actual changed paths, not named in the dispatch
list), and
packages/speccheck:generatedreports all 13 artifacts up to dateafter 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-todoat 4 files / 106 testspassing.
Generated by Claude Code