Skip to content

Commit 2aa25ef

Browse files
fix(spec): os migrate meta guidance for the engine-* migration entries states each lesson in words, not tracker numbers (stage 1) (#20285)
Part of #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 | |---|---|---| | `#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" | | `#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, …" | | `#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" | | `#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" | | `#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" | | `#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" | | `#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" | | `#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 …" | | `#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" | | `#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)" | | `#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" | | `#7588` | The PR that implemented `#7532`. | folded into the `#7532` sentence | | `#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" | | `#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" | | `#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" | | `#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" | | `#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" | | `#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`" | | `#8369` | The PR that implemented `#8296`. | folded into the `#8296` sentence | | `#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" | | `#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" | | `#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 #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 '#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>
1 parent 2dccb7d commit 2aa25ef

10 files changed

Lines changed: 346 additions & 125 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): `os migrate meta` guidance for the `engine-*` migration entries states each lesson in words instead of citing tracker numbers
6+
7+
Clause-②: no
8+
9+
The five ADR-0087 semantic entries about the data engine's query and write
10+
options (`engine-dotted-projection-refused`, `engine-find-formula-filter-refused`,
11+
`engine-find-formula-order-by-refused`, `engine-update-upsert-retired`,
12+
`engine-dotted-filter-refused`) are printed by `os migrate meta` as the
13+
replacement, `why:` and `verify:` lines of a manual change. Their text sent the
14+
reader to issue-tracker numbers for what a ruling or a fix had decided; it now says
15+
what was decided, in the sentence being read. ADR ids are kept.
16+
17+
Text only: no entry id, surface, `from` / `to`, conversion or matching logic
18+
changes, and the chain rewrites exactly what it rewrote before. The generated
19+
migration registry, `spec-changes.json` and the protocol upgrade guide carry the
20+
same text.

‎docs/protocol-upgrade-guide.md‎

Lines changed: 14 additions & 14 deletions
Large diffs are not rendered by default.
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate meta` — the guidance it prints for the `engine-*` ADR-0087
5+
* semantic entries states each lesson in words and carries no tracker number.
6+
*
7+
* ## What this pins
8+
*
9+
* Every semantic entry the replayed chain crosses is printed to the author as
10+
* one block — `⚠ [protocol N] <surface> → <replacement>`, then `why:` (the
11+
* entry's `reason`) and `verify:` (its `acceptanceCriteria`). That is text an
12+
* author is shown, so it carries no tracker number: a number sends the reader
13+
* to a page that can be deleted (some cited pages already had been), and the
14+
* lesson the entry exists to teach then sits behind a dead link instead of in
15+
* the sentence being read. The `engine-*` entries were rewritten to say what
16+
* each cited ruling, measurement or fix decided; ADR ids stay, because an ADR
17+
* lives in this repository.
18+
*
19+
* The fixture authors the shapes those entries are about — a lookup and a
20+
* virtual `formula` field on one object — and the CLI replays the chain from
21+
* the support floor to the highest major carrying an `engine-*` entry. Each
22+
* family block is then located VERBATIM in what the terminal printed, and that
23+
* printed block must hold no `#` followed by four or five digits.
24+
*
25+
* ## Why it cannot pass by reading nothing
26+
*
27+
* - The family is derived from the registry by id prefix, so an `engine-*`
28+
* entry added later is held to the same line on arrival — and the derived set
29+
* must still contain the five entries this rewrite covered, so an emptied
30+
* prefix cannot turn every assertion below into a loop over nothing.
31+
* - Each block is asserted PRESENT in stdout before it is asserted clean, so a
32+
* renderer change that stopped printing the prose fails here instead of
33+
* passing on an absent string.
34+
* - The detector is exercised on both sides before it is trusted: it fires on
35+
* a four- and a five-digit tracker id and stays dark on three or six digits
36+
* and on an ADR id.
37+
*
38+
* ## Why a spawn, and why this file is QUEUE tier rather than `.e2e`
39+
*
40+
* The subject is the sentence a real terminal prints, which is assembled in the
41+
* command's human-output branch — above every seam an in-process test reaches.
42+
* So the CLI is spawned once and the one run is shared by every assertion. The
43+
* file deliberately does NOT carry the `.e2e` name: that name selects the
44+
* nightly population (`vitest-tiers.ts` → "The NIGHTLY tiers"), and a pin that
45+
* runs only nightly is not protected by the merge queue's required set. Queue
46+
* tier by name, `integration` by behaviour — the same combination
47+
* `migrate-meta-default-range.test.ts` records.
48+
*/
49+
50+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
51+
import { execFile } from 'node:child_process';
52+
import { promisify } from 'node:util';
53+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
54+
import { tmpdir } from 'node:os';
55+
import { join, resolve } from 'node:path';
56+
import { fileURLToPath } from 'node:url';
57+
import { MIGRATIONS_BY_MAJOR, MIGRATION_SUPPORT_FLOOR } from '@objectstack/spec';
58+
import { childEnv } from './helpers/serve-process.js';
59+
60+
const execFileP = promisify(execFile);
61+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
62+
const CLI = resolve(HERE, '../bin/run-dev.js');
63+
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
64+
65+
/** A tracker id as author-shown prose must not carry it: `#` and four or five digits. */
66+
const TRACKER_ID = /#\d{4,5}\b/;
67+
68+
/** The family this pin holds, selected by entry-id prefix. */
69+
const FAMILY_PREFIX = 'engine-';
70+
71+
/** The entries rewritten when the family was brought to this line — the anti-vacuity floor. */
72+
const REWRITTEN = [
73+
'engine-dotted-filter-refused',
74+
'engine-dotted-projection-refused',
75+
'engine-find-formula-filter-refused',
76+
'engine-find-formula-order-by-refused',
77+
'engine-update-upsert-retired',
78+
];
79+
80+
interface FamilyEntry {
81+
toMajor: number;
82+
id: string;
83+
surface: string;
84+
replacement: string;
85+
reason: string;
86+
acceptanceCriteria: string;
87+
}
88+
89+
const FAMILY: FamilyEntry[] = Object.entries(MIGRATIONS_BY_MAJOR).flatMap(([major, step]) =>
90+
step.semantic
91+
.filter((s) => s.id.startsWith(FAMILY_PREFIX))
92+
.map((s) => ({ ...s, toMajor: Number(major) })),
93+
);
94+
95+
/** The block the command prints for one semantic TODO, exactly as `meta.ts` lays it out. */
96+
function printedBlock(e: FamilyEntry): string {
97+
return (
98+
`⚠ [protocol ${e.toMajor}] ${e.surface} → ${e.replacement}\n`
99+
+ ` why: ${e.reason}\n`
100+
+ ` verify: ${e.acceptanceCriteria}`
101+
);
102+
}
103+
104+
/**
105+
* A stack authoring the shapes the family's entries are about: a relation a
106+
* dotted path would follow, and a virtual `formula` field no driver
107+
* materialises a column for.
108+
*/
109+
const FAMILY_FIXTURE = `
110+
export default {
111+
manifest: { id: 'com.example.engine-guidance-pin', name: 'Engine Guidance Pin', version: '1.0.0', type: 'app' },
112+
objects: [
113+
{ name: 'pin_project', label: 'Project', fields: { name: { type: 'text', label: 'Name' } } },
114+
{
115+
name: 'pin_task',
116+
label: 'Task',
117+
fields: {
118+
title: { type: 'text', label: 'Title' },
119+
status: { type: 'text', label: 'Status' },
120+
project_id: { type: 'lookup', label: 'Project', reference: 'pin_project' },
121+
is_open: { type: 'formula', label: 'Open', expression: 'record.status == "open"' },
122+
},
123+
},
124+
],
125+
};
126+
`;
127+
128+
let dir: string;
129+
let stdout: string;
130+
131+
beforeAll(async () => {
132+
dir = mkdtempSync(join(tmpdir(), 'os-migrate-meta-engine-guidance-'));
133+
writeFileSync(join(dir, 'objectstack.config.ts'), FAMILY_FIXTURE);
134+
const toMajor = Math.max(...FAMILY.map((e) => e.toMajor));
135+
const run = await execFileP(
136+
TSX,
137+
[CLI, 'migrate', 'meta', '--from', String(MIGRATION_SUPPORT_FLOOR), '--to', String(toMajor)],
138+
{ cwd: dir, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
139+
);
140+
stdout = run.stdout;
141+
}, 120_000);
142+
143+
afterAll(() => {
144+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
145+
});
146+
147+
describe('os migrate meta — the engine-* guidance carries no tracker number', () => {
148+
it('the detector fires on a tracker id and stays dark on every other number shape', () => {
149+
expect(TRACKER_ID.test(`see #${'9'.repeat(4)}`)).toBe(true);
150+
expect(TRACKER_ID.test(`see #${'9'.repeat(5)}`)).toBe(true);
151+
expect(TRACKER_ID.test(`see #${'9'.repeat(3)}`)).toBe(false);
152+
expect(TRACKER_ID.test(`see #${'9'.repeat(6)}`)).toBe(false);
153+
expect(TRACKER_ID.test('ADR-0112')).toBe(false);
154+
});
155+
156+
it('selects the whole family, including every entry the rewrite covered', () => {
157+
const ids = FAMILY.map((e) => e.id);
158+
for (const id of REWRITTEN) expect(ids, `family lost ${id}`).toContain(id);
159+
});
160+
161+
it('prints every family block verbatim, and no printed block names a tracker id', () => {
162+
for (const e of FAMILY) {
163+
const block = printedBlock(e);
164+
expect(stdout.includes(block), `${e.id}: its block is not in the printed output`).toBe(true);
165+
expect(block.match(TRACKER_ID)?.[0], `${e.id}: the printed guidance cites a tracker id`)
166+
.toBeUndefined();
167+
}
168+
});
169+
});

0 commit comments

Comments
 (0)