Repository navigation
docs(spec): fields stops prescribing a dotted path no driver resolves (#7601) - #7617
Conversation
…es (#7601) OS-DEV-REPORT ============= STATUS: complete — all six carded surfaces addressed, plus four extras the completeness sweep found beyond the card's enumeration. Every named gate green. BRANCH: claude/issue-7601-dotted-fields-prose (cut from origin/main @ 59768f7, fresh fetch; dedicated worktree; no `git stash` used anywhere) ISSUE: #7601 — claimed by the PM spec-surface seat (comment 5250873539). Not re-claimed by this session. PREMISE STILL VALID — ANCHOR RE-VERIFICATION @ 59768f7 ------------------------------------------------------ Triage verified the anchors at 2c1988c; #7534 and #7588 merged since. Re-measured at my fetched head. The premise holds in full: every surface still offered the dotted spelling, and the enforcement side (`assertProjectionFieldsExist`) is still live. Line-number drift and one structural change absorbed: 1 query.zod.ts:484 describe -> STILL AT :484, verbatim. No drift. 2 recursive-schema-input-assertions -> STILL AT :74, :99. No drift. 3 17.query-joins-retired.ts:10 -> STILL AT :10, verbatim. No drift. 4 17.query-field-node-object-form- -> STILL AT :8, verbatim. No drift. retired.ts:8 5 json-schema/** artifacts -> ** STRUCTURAL DRIFT — see below ** 6 query-syntax.mdx:140 -> STILL AT :140. No drift. query-syntax.mdx:972 -> DRIFTED to :1007-1011 (the "Joins — removed (#4286)" section). Same text, ~35 lines later. references/data/query.mdx:125,134 -> STILL AT :125, :134. GENERATED (confirmed: build-docs.ts owns all of content/docs/references/**). references/api/contract.mdx:407 -> STILL AT :407. Same, generated. SURFACE 5 IS STALE IN THE CARD — `packages/spec/json-schema/` IS GITIGNORED --------------------------------------------------------------------------- The card calls these "shipped JSON Schema artifacts ... commit the artifacts", and the dispatch brief says the same. That is no longer true at my head: `.gitignore:61` carries `packages/spec/json-schema/`, and `git ls-files` returns nothing under it. The tree is rebuilt by `pnpm gen:schema` as the first step of `pnpm build`; it is a build output, not a tracked artifact. (The ignore predates this card — earliest touch found on that .gitignore line is 2c28df9.) Handled correctly either way: I ran the generator (`gen:schema`), confirmed the regenerated tree carries the corrected text and zero occurrences of the old "optionally dotted" string, and `check:authorable-surface` — the gate that verifies this tree against source — is green. There is simply nothing to commit for surface 5. The regeneration reached WIDER than the card's three files: 8 schema files carry the `fields` description (FindDataRequest, ExportRequest, objectstack.json, Query, DataEngineFindRequest, DataEngineFindOneRequest, DataEngineRequest, EngineQueryOptions), all updated. DIRECTION HELD (not re-litigated) --------------------------------- #7532's ruling taken as settled: dotted projections are refused at the ingress gate; dotted-path RESOLUTION is not authorised and was not implemented here. `FieldNodeSchema` was NOT narrowed — it remains `z.string()`. Verified byte- identical input validity: `packages/spec/src/data/query.test.ts` still asserts `FieldNodeSchema.parse('owner.name')` returns `'owner.name'` and that `QuerySchema.parse({ object: 'task', fields: ['title','owner.name'] })` does not throw, and both pass unchanged. Shape vs semantics split preserved throughout. PER-SURFACE: BEFORE -> AFTER ---------------------------- [1] packages/spec/src/data/query.zod.ts:484 — the normative `fields` describe BEFORE: "Fields to retrieve — field names, optionally dotted to reach through a relationship (`owner.name`). Related *records* are selected with `expand`, not from inside this list." AFTER: "Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD` (#7532). Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes (#6924)." All four required elements present: dotted path no longer offered; `expand` named as the sanctioned mechanism with its filter+select capability; the FK- retention sharp edge carried verbatim in substance with the card's exact `fields: ['title'] / project_id` example; denormalisation named in #6924's own words ("a stored field, written when the source changes", lifted from 17.engine-find-formula-order-by-refused.ts:11-12). Same file, three SIBLING prescriptions corrected — they sit inside the normative source and would have contradicted the describe line-for-line if left: :276-282 joins-removal source comment. "Related records are read through `expand`; a single related column is a dotted `fields` path (`fields: ['owner.name']`)." -> related records AND single related columns both via `expand`'s nested `fields`; dotted named as refused. :296-302 the `FieldNode` type docblock. "The whole vocabulary is a column (`'name'`) or a dotted path the engine resolves through a relationship field" — factually false, no engine resolves it. -> own columns only, with the shape-vs-semantics split stated explicitly (parses, resolves nothing). :323 FIELD_NODE_OBJECT_FORM_REMOVED — the runtime rejection message an author actually reads. "...or name a single related column with a dotted path (`fields: ['owner.name']`)." -> `expand` keyed on the FK with the retention rule; dotted named as NOT the replacement. :361 QUERY_JOINS_REMOVED — same, for `query.joins`. This one is the source of the generated `joins` rows in BOTH reference docs (query.mdx:134, contract.mdx:416), so correcting it fixed those too. [2] packages/spec/src/recursive-schema-input-assertions.ts:74,99 — the type pins DECISION: **rename + comment** (both were permitted; reasoning below). :74 `queryInput` — the file's one canonical query, labelled "The authoring shape". BEFORE: `fields: ['name', 'owner.email']`, `expand: { owner: ... }`. AFTER: `fields: ['name', 'owner_id']`, `expand: { owner_id: ... }`, with a docblock noting the projection keeps the FK that `expand` resolves through. Still pins exactly what it pinned (only `object` required; `expand` recurses) — the change is which query it uses as the exemplar. :99 `fieldNodeDotted` -> `fieldNodeDottedNotNarrowed`, value unchanged (`'owner.email'`), under a new docblock section headed "The dotted probe is a NON-NARROWING guard, not an endorsement (#7601)". REASONING. Keep-with-comment was the cheaper option and I rejected it. A pin's name is what a future reader sees at the call site and in a failure report; a comment is what they see only if they scroll to the declaration. `fieldNodeDotted` states "there is a dotted field-node concept" in the one place the comment cannot reach. Renaming to `fieldNodeDottedNotNarrowed` makes the pin state its actual job — it fails if someone "fixes" this defect by narrowing `FieldNodeSchema`, which is precisely the move #7601 forbids and which a reader arriving from the corrected prose is most likely to attempt. The assertion itself is untouched and still passes, so the guard is strictly strengthened in legibility, not weakened. The rename is safe: `grep` over packages/ and scripts/ finds zero other references, and the file is not on the public export surface (absent from api-surface/ and export-origins/ — `check:api-surface` and `check:export-origins` both green, confirming no published symbol moved). I also renamed the RUNTIME twin of this pin for the same reason — `packages/spec/src/data/query.test.ts:129`, "accepts a field name, and a dotted path through a relationship" -> "accepts a field name, and still parses a dotted string (shape, not semantics)", with a comment carrying the same non-narrowing argument. The test body is unchanged and passes; only the sentence a failure report prints changed. [3] .../semantic/17.query-joins-retired.ts:10 — the `query.joins` prescription BEFORE (replacement): "expand (`expand: { owner: {...} }`), or a dotted `fields` path for a single related column (`fields: ['owner.name']`)" AFTER: `expand` keyed on the FK, whose nested query selects the related record's own columns; the FK-retention rule spelled out; "A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD`, #7532)"; denormalisation named in #6924's words. acceptanceCriteria also corrected — it independently prescribed "single related columns through dotted `fields` paths", which the card's anchor list did not catch. Now: related records AND single related columns through `expand`, FK retained. `reason` deliberately UNCHANGED: it narrates the PAST retirement (#4286 / why `joins` was inert) and its historical clauses are accurate. Only the forward- looking prescription moved, per the tombstone rule. [4] .../semantic/17.query-field-node-object-form-retired.ts:8 — same fix, same replacement text, same acceptanceCriteria correction ("single related columns through dotted paths" -> through `expand` with the FK retained). `reason` untouched for the same reason. [5] packages/spec/json-schema/** — regenerated via the package's own `gen:schema` (build-schemas.ts). Never hand-edited. See the STALE CARD note above: the tree is gitignored, so nothing is committed; `check:generated` verifies it against source and is green. [6] Docs prose content/docs/protocol/objectql/query-syntax.mdx — HAND-WRITTEN (measured: the generator owns only content/docs/references/**). :140-142 the `FieldNode` code-comment in the AST listing. Rewritten to own columns + the parses-but-resolves-nothing split. :1009-11 (card's ":972", drifted) the "Joins — removed" advice, which offered "a dotted `fields` path (`'owner.name'`) for a single related column". Rewritten: `expand` for related records and single related columns alike; a bolded paragraph naming the dotted path as not the alternative, with the refusal and the denormalisation remedy; and a `<Callout type="warn">` (the page's own idiom, used 4x above) that carries the FK-retention edge as the card's two-line before/after code block. This is the sharp edge's first appearance in prose anywhere — it was pinned by #7588's GUARD test and documented nowhere. content/docs/references/data/query.mdx:125,134 and content/docs/references/api/contract.mdx:407,416 — GENERATED (measured, not assumed: build-docs.ts:5 declares it regenerates ALL of content/docs/references/{category}/). Regenerated via `gen:docs`; both the `fields` row and the `joins` row follow from surface 1 and surface [1]:361 respectively. Not hand-edited. content/docs/releases/ — NOT TOUCHED. Confirmed zero paths under it in the diff. COMPLETENESS SWEEP — THE CARD'S SIX WERE NOT COMPLETE ------------------------------------------------------ Swept the whole tree for the dotted spelling (`owner.name` / `owner.email` / "optionally dotted" / "dotted ... path") across .ts/.tsx/.mdx/.md/.json. #7588's enumeration missed four live forward-looking prescriptions. Three were the identical one-line prose fix and are FIXED here; one is a different defect in another package and is reported below, unfixed. FIXED (identical one-line prose fix, in scope): a) content/docs/kernel/contracts/data-engine.mdx:108 — the `EngineQueryOptions` interface listing: `fields?: FieldNode[]; // SELECT — field names ('name', 'owner.name')`. Hand-written page, same prescription, now names own columns and points at `expand`. b) .changeset/query-field-node-object-form-removed.md:43 — a PENDING (unversioned) changeset whose FROM -> TO table gave `fields: ['owner.name']` (dotted path) as the "Now" for the one-related-column case. This is a release-notes INPUT, so left alone it would have compiled the refused spelling into the v17 notes at release time — the exact failure mode the release-notes rule exists to prevent, arriving through the sanctioned channel. Row corrected to `expand` + FK retention. (Editing the pending changeset, not content/docs/releases/ — the compiled page is untouched.) c) The four in-file siblings in query.zod.ts (:276, :296, :323, :361) already itemised under surface [1], and the runtime pin in query.test.ts under surface [2]. Listed here so the count is honest: 6 carded surfaces + 4 extras found by sweep + 5 in-file/in-test siblings = 15 edited locations. VERIFIED-CLEAN, no change needed: d) content/docs/data-modeling/queries.mdx:308-316 — reads as a hit but is already CORRECT and unusually good: it narrates the dotted path as broken, names the `SELECT *` widening (#7589) as the observed result, and prescribes `expand`. Left exactly as is. e) All CHANGELOG.md hits and content/docs/releases/v17.mdx:977 — historical narration of the #4196/#4286 retirements as they shipped. Out of bounds by the tombstone rule and by the releases rule. Untouched. f) `{record.account.name}` / `{owner.name}` template hits (service-automation, formula, lint, trigger-record-change), `collectConditionFields({'owner.name'})` in plugin-security, and the analytics dataset dimension `{ field: 'owner.name' }` — different axes (flow templates, filter keys, dataset compiler), not `fields[]` projection. Not in scope, not touched. FINAL SWEEP RESULT: zero forward-looking dotted-`fields` prescriptions remain outside deliberate historical narration. Every surviving `owner.name` in a projection context is either (i) my corrected prose naming the spelling in order to refuse it, (ii) a tombstone/CHANGELOG narrating the past, or (iii) the non-narrowing pins, which now say so in their names. VERIFICATION ------------ pnpm --filter @objectstack/spec check:generated -> "All 13 generated artifacts are up to date." (13/13 green, including check:docs, check:authorable-surface, check:migration-registry, check:spec-changes, check:upgrade-guide, check:api-surface, check:export-origins) NOTE: check:api-surface reads the BUILT dist and reported red on a fresh checkout before any of my edits (dist absent — "the package is not built"). I ran `pnpm --filter @objectstack/spec build` and re-ran the aggregate; green. The earlier red was the unbuilt-dist precondition, not this diff. pnpm --filter @objectstack/spec check:docs -> "231 generated files in sync with packages/spec" pnpm --filter @objectstack/spec typecheck -> tsc --noEmit clean; check:scripts-typecheck clean; check:test-typecheck OK (debt ledger unchanged at 57 files / 265 errors — not grown) pnpm --filter @objectstack/spec test -> 376 files / 9867 tests passed, 0 failed. Includes the surface-2 pins: the renamed runtime pin passes, and the two retirement-message tests still match the rewritten prescriptions. node scripts/check-nul-bytes.mjs (+ --self-test) -> OK, 7056 text files, no raw ASCII control bytes node scripts/check-doc-authoring.mjs (+ --self-test) -> 375 files clean node scripts/check-empty-changeset.mjs -> green node scripts/check-adr-0087-registration.mjs -> green (no declared- breaking changeset) node scripts/check-changeset-no-major.mjs -> green (no major bump) npx eslint on all five edited .ts files -> clean CHANGESET --------- .changeset/dotted-fields-prose-corrected.md — `patch` for "@objectstack/spec", matching triage's call. Patch is right: the published `.describe()` text and the shipped JSON Schema descriptions move, which is a real change to what the package publishes, but no type, no runtime validation and no wire behaviour changes, and every previously-valid input remains byte-identically valid. The changeset states the no-narrowing decision explicitly so the release notes carry it. OUT-OF-SCOPE FINDINGS (not fixed here — for the PM to route) ------------------------------------------------------------- * packages/objectql/src/engine.ts:6760-6763 — a FALSE implementation comment, and the live code it explains: // Keep relationship paths like `owner.name` — the engine will // resolve those via populate; only validate top-level segment. const head = f.split('.')[0]; return known.has(head); There is no populate step in the engine — content/docs/data-modeling/queries.mdx says so in as many words, and #7601's own measurement confirms it. This is the head-only validation that lets a dotted entry survive into the driver, where it becomes the #7589 `SELECT *` widening. It is `domain:drivers`/objectql, it is code not prose, and it sits inside the #7589 mechanism, so I did not touch it — but the comment is the last place in the repo still asserting that resolution exists, and it will mislead whoever picks up #7589. Suggest folding the comment correction into #7589 rather than a new card. * The card's surface-5 description is stale (json-schema/ is gitignored, not shipped from the repo). Worth correcting in whatever template produced it, since the same "commit the artifacts" instruction will misroute the next card in this family. OPEN QUESTIONS -------------- None blocking. Two judgment calls made and argued rather than escalated, both reversible in review: 1. The type-pin disposition (rename vs keep-with-comment) — the card explicitly left this to the executor; reasoning is under surface [2]. 2. content/docs/protocol/objectql/index.mdx:132-176 was NOT on the card's list and is more than a one-line fix, so flagging it explicitly. It is the docs' single most prominent query example — the "Query Language: Database-Agnostic AST" showcase — and it both used `fields: [..., 'owner.name']` AND claimed the runtime compiles it to a `LEFT JOIN u ON c.owner_id = u.id` (Postgres) and a `$lookup` (MongoDB). Neither happens: `expand` is a second batched `$in` read, driver-agnostic by design. Leaving the headline example teaching the refused spelling while correcting five quieter surfaces would have left the repo self-contradicting, so I fixed it: the query now selects own columns + the FK and expands through it, and the two translations show the single-table SELECT followed by the batched related read that actually runs. If the PM would rather this were its own card, it reverts cleanly as one hunk in one file. Fixes #7601 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GZuvmrzekxHCf2bgVb3Swn
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
…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 #7601
What this does
Six in-repo surfaces offered
fields: ['owner.name']as the supported way to read one related column. No driver implements it, and since #7532 the ingress additionally refuses it with400 INVALID_FIELD. This aligns the declaration to the enforcement — prose and the artifacts generated from it, per #7532's settled ruling.⛔
FieldNodeSchemais NOT narrowed — it staysz.string(). Every input valid before this change parses byte-identically after it; the runtime pin asserting exactly that is kept and passes unchanged. The refusal is a semantic verdict at the ingress gate, not a shape check.Index into the commit report
59768f7. One line drift (query-syntax.mdx:972→:1007); one structural finding below.packages/spec/json-schema/is gitignored at head (.gitignore:61) — a build output, not a tracked artifact. Regenerated and gate-verified; nothing to commit. Regeneration reached 8 schema files, wider than the card's three.query.zod.ts(:276,:296,:323,:361) that would otherwise contradict the corrected describe line-for-line.fieldNodeDotted→fieldNodeDottedNotNarrowed; not on the public export surface (check:api-surfacegreen).Sweep extras beyond the card's six
content/docs/kernel/contracts/data-engine.mdx:108— fixed..changeset/query-field-node-object-form-removed.md:43— a pending changeset whose FROM → TO table gave the dotted path as the "Now". Left alone it would compile the refused spelling into the v17 release notes through the sanctioned channel. Row corrected. (content/docs/releases/itself is untouched — confirmed zero paths under it in the diff.)content/docs/protocol/objectql/index.mdx:132-176— flagged in the report as the one judgment call worth a second look: the docs' headline query example both used'owner.name'and claimed the runtime compiles it to aLEFT JOIN/$lookup. Neither happens. Fixed; reverts cleanly as one hunk if the PM would rather it be its own card.packages/objectql/src/engine.ts:6760— not fixed, reported for routing: a false comment claiming "the engine will resolve those via populate". No populate step exists. Suggest folding into finding: SqlDriver's #3821 recovery ladder widens an unresolvable projection to every field #7589 rather than a new card.Verification
check:generated— 13/13 green (check:docs,check:authorable-surface,check:migration-registry,check:spec-changes,check:upgrade-guide,check:api-surface,check:export-origins, …)pnpm --filter @objectstack/spec test— 376 files / 9867 tests passed, 0 failedtypecheck— clean; test-typecheck debt ledger unchanged (not grown)check-nul-bytes,check-doc-authoring, the three changeset gates,eslinton all edited.ts— greenChangeset:
patchfor@objectstack/spec(published describe text + shipped JSON Schema descriptions move; no wire, type or validation change).Generated by Claude Code