Skip to content

fix(metadata-protocol): refuse a dotted projection instead of widening the response to every field (#7532) - #7588

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-7532-dotted-projection-widens
Aug 11, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-7532-dotted-projection-widens

Conversation

@claude

@claude claude Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #7532

What was wrong

POST /api/v1/data/showcase_invoice/query {"fields":["name","account.name"]} answered 200 carrying every business field — strictly more data than was asked for — and no resolved account.name. GET ...?$select=name,account.name did the same.

assertProjectionFieldsExist validated only f.split('.')[0], so a dotted entry cleared the #4226 unknown-name gate on its head segment (account really is a field) and travelled on to the driver as a projection column.

Where the widening actually happens

The card attributed the fallback to the ingress gate. It is one layer lower, and I measured both:

  • The protocol does not drop the name. Driven through protocol.findData against a stub driver, fields:['title','project_id.name'] reaches the driver as ["title","project_id.name"] — forwarded verbatim.
  • SqlDriver widens it. Driven against a real SqlDriver (better-sqlite3, :memory:), calling driver.find directly:
no projection                  -> account amount created_at id name status updated_at
fields ['name']                -> name                        <- a plain name narrows
fields ['name','account.name'] -> account amount created_at id name status updated_at
fields ['account.name']        -> account amount created_at id name status updated_at

The dotted rows are byte-identical to no projection at all. Knex renders "account"."name" against a table that was never joined, sqlite answers no such column, and the #3821 recovery ladder retries select('*') because rows matter more than the projection.

Per ruling, this PR closes the ingress gate and leaves the driver ladder alone; packages/driver-sql is untouched. The driver behaviour is filed separately as a finding: #7589, carrying this measurement and a drop-in reproduction harness. It is defence-in-depth behind this gate rather than a live user-facing defect once this lands — but it still governs every caller that reaches SqlDriver without passing this ingress (hooks, flows, reports, expand sub-reads, registry-less hosts).

What this does

A dotted entry on the projection axis is now 400 INVALID_FIELD, ordered unknown > dotted so this axis reports the same complaint first as assertSortFieldsExist already does. Two messages, because the fixes differ:

  • head is a reference field → names the relationship crossed, prescribes expand (or denormalising the value onto the queried object).
  • head is a real but non-reference column → says a dotted path reaches only whole columns, and to read into the value in the caller. expand would be the wrong prescription here.

This also settles the card's second complaint: an unknown plain column was a 400 while an unknown dotted one was a 200 with every field — one mistake, opposite verdicts on one endpoint, depending on spelling.

Precedent. #5918 faced this exact shape on the analytics measures axis and ruled the same way — refuse the dotted member loudly, naming the caller's original spelling, because there is no correct answer to converge on. That is also the distinction from #5739, where refusing would have rejected queries that already compiled correctly.

Is expand the sanctioned mechanism for related data on this door?

Yes — and after this PR it is the only one. Confirmed against query-syntax.mdx §4 and the spec: expand resolves reference fields into the related record via batched $in reads, driver-agnostic, and its nested QueryAST can both filter (where) and select (fields) the related record's columns. It is what the refusal points at, and a GUARD test pins that it still delivers.

One sharp edge, measured while writing that test and pinned: the projection must retain the foreign-key column. fields:['title'], expand:'project_id' projects the FK away and expansion has nothing to resolve; fields:['title','project_id'] works. Worth knowing since this PR now routes callers here.

⚠️ Follow-up this PR deliberately does not do

Several in-repo surfaces still offer a dotted fields path as the way to read one related column. Every one of them describes behaviour no driver implements — they were already wrong before this PR, and they are now also contradicted by a 400:

surface location
spec fields description (normative, generates the JSON Schema + contract docs) packages/spec/src/data/query.zod.ts:484
type pins packages/spec/src/recursive-schema-input-assertions.ts:74,99
query.joins retirement prescription packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts:10
nested-select retirement prescription .../17.query-field-node-object-form-retired.ts:8
shipped JSON Schema artifacts packages/spec/json-schema/api/{FindDataRequest,ExportRequest}.json, objectstack.json
docs content/docs/protocol/objectql/query-syntax.mdx:140,972; content/docs/references/data/query.mdx:125,134; content/docs/references/api/contract.mdx:407

Aligning these is cross-package spec/docs surface that regenerates artifacts, and doing it inside this PR while #7534 is in flight is exactly the collision the serialization constraint exists to prevent.

Filed as #7601 — carrying this table (normative surface first), the measurement that every one of them describes behaviour no driver implements, the coherent-split note (the zod schema still parses a dotted string because it is a shape check — the fix is the .describe() prose and the generated artifacts, not the schema type), and the expand foreign-key edge below so the corrected prose carries it. Ruled a restore-invariant fix (declared = enforced), not an open product question; scheduling is what kept it out of this PR.

Note the zod schema still parses a dotted string (it is a shape check, z.string()); the semantic refusal is this ingress gate. That split is coherent, but the .describe() prose is now misleading.

File surface

file change
packages/metadata-protocol/src/protocol.ts assertProjectionFieldsExist gains the dotted verdict after the unknown one; shape-rejection hint no longer prescribes select=owner.name; docblock records the measurement
packages/objectql/src/query-expression-conformance.test.ts inverts the test that pinned the old behaviour; adds 11 tests (4 GUARD)
.changeset/dotted-projection-refused.md patch, @objectstack/metadata-protocol

resolveQueryFields is unchanged — the projection path only calls into it. packages/driver-sql is untouched.

The inverted test

query-expression-conformance.test.ts carried it('a dotted path is still accepted — the replacement the rejection prescribes'), asserting findData({select:'parent_id.title'}) resolves, on the reasoning that "the head segment is validated here, the tail resolved downstream". The tail is resolved nowhere — the measurement above is what that test was actually protecting. It now asserts the refusal and records why it flipped.

Reverse verification

Predictions written before running. Source reverted via patch file (never git stash), new tests retained.

test predicted on revert
dotted refused — POST /query body fields RED RED ✅
dotted refused — GET ?$select= RED RED ✅
dotted refused — GET ?select= RED RED ✅
dotted refused — fields comma string RED RED ✅
projection that is ONLY a dotted path RED RED ✅
message names relation + prescribes expand RED RED ✅
non-relation dotted head gets other wording RED RED ✅
inverted conformance test RED RED ✅
GUARD plain projection narrows to exact key set green both green ✅
GUARD same control through the GET door green both green ✅
GUARD unknown plain column still 400 (#4226) green both green ✅
GUARD unknown HEAD still the unknown verdict (precedence) green both green ✅
GUARD expand still delivers related record green both green ✅

8 failed | 122 passed on revert — exactly the 8 predicted, no others. No missed predictions.

The narrowing assertions are pinned as exact key sets (expect(Object.keys(record).sort()).toEqual([...])), not absence checks — an over-return defect passes any assertion written only as "does not contain X".

Gates run

gate result
@objectstack/metadata-protocol suite 71 files, 1051 tests, pass
@objectstack/objectql suite 178 files, 3165 tests, pass
@objectstack/rest suite (direct consumer) 82 files, 1341 tests, pass
tsc --noEmit (metadata-protocol) 63 errors, identical count at base SHA — all pre-existing, in unrelated test files; none in protocol.ts
eslint --no-inline-config on both changed files clean

Consumption radius

assertProjectionFieldsExist is private with exactly two call sites, both in protocol.ts — findData (the list door: POST /query body fields, GET ?$select=/?select=, all folded into fields by WIRE_QUERY_ALIAS_SLOTS before the gate) and getData (GET /:object/:id?select=). Both card doors verified to route through it. No external callers.

Swept packages/ and examples/ for non-test source passing a dotted projection: none. Nothing in-tree breaks.

Not measured

Mongo and the memory driver (both resolve dotted paths against the row, where a FK is a scalar id — likely undefined rather than a widening, but I did not drive them). Live HTTP; I measured at protocol.findData and at driver.find, not over the wire. Showcase seed data.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TXK7QuSmTF3DKWji91cmkn


Generated by Claude Code

…g to every field (#7532)

`POST /api/v1/data/:object/query {"fields":["name","account.name"]}` answered 200
carrying every business field — strictly more than was asked for — and no resolved
`account.name`. `GET ?$select=name,account.name` did the same.

`assertProjectionFieldsExist` validated only `f.split('.')[0]`, so a dotted entry
cleared the #4226 unknown-name gate on its head segment and reached the driver as a
projection column. Measured on a real SqlDriver (better-sqlite3), a dotted projection
comes back byte-identical to no projection at all: Knex renders `"account"."name"`
against a table that was never joined, sqlite answers `no such column`, and the
#3821 recovery ladder retries `select('*')`.

A dotted entry is now 400 INVALID_FIELD, ordered `unknown` > `dotted` to match
`assertSortFieldsExist`. The message names the relationship it crossed and sends the
caller to `expand`; a dotted path on a real non-reference column gets its own wording.
Follows #5918, which ruled the same way on the analytics measures axis for the same
reason — there is no correct answer to converge on.

The shape rejection's hint no longer prescribes `select=owner.name`: it pointed at the
widening defect, the dead end #6924 removed from the sort axis' hint.

`resolveQueryFields` is unchanged (additive call only, #7534 in flight on the filter
axes). SqlDriver's recovery ladder is deliberately untouched and filed separately.

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

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 6:34am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

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

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/metadata-protocol)

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

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

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

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 11, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 11, 2026 07:06
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 8201000 Aug 11, 2026
28 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-7532-dotted-projection-widens branch August 11, 2026 07:29
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…es (objectstack-ai#7601) (objectstack-ai#7617)

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: objectstack-ai#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; objectstack-ai#7534 and objectstack-ai#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 (objectstack-ai#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)
---------------------------------
objectstack-ai#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` (objectstack-ai#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 (objectstack-ai#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 objectstack-ai#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 (objectstack-ai#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 objectstack-ai#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`, objectstack-ai#7532)"; denormalisation named
          in objectstack-ai#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 (objectstack-ai#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 objectstack-ai#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. objectstack-ai#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 (objectstack-ai#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 objectstack-ai#4196/objectstack-ai#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 objectstack-ai#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 objectstack-ai#7589 `SELECT *` widening. It is `domain:drivers`/objectql, it is
  code not prose, and it sits inside the objectstack-ai#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 objectstack-ai#7589. Suggest folding the comment
  correction into objectstack-ai#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 objectstack-ai#7601


Claude-Session: https://claude.ai/code/session_01GZuvmrzekxHCf2bgVb3Swn

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

The v17.mdx removed-features row for the fields[] object form
prescribed a dotted path (fields: ['owner.name']) as a fallback
alongside expand. That spelling is now refused at both doors (REST
ingress since objectstack-ai#7532/PR objectstack-ai#7588, engine find/findOne since objectstack-ai#7589/PR
objectstack-ai#8327), so the remedy pointed upgraders straight into the refusal
it was meant to route around.

Per the card's own prescription: drop the dotted alternative, keep
expand alone with the reference column preserved in the projection.

Fixes objectstack-ai#8329


Claude-Session: https://claude.ai/code/session_01Jqe56GnYFddggeAyfkZFVz

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

Part of objectstack-ai#20233

Clause-②: no

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

## What this does

`os migrate meta` prints every ADR-0087 semantic entry it crosses as one
block: `⚠ [protocol N] surface → replacement`, then `why:` (the entry's
`reason`) and `verify:` (its `acceptanceCriteria`). Those three fields
are text an author is shown, and AGENTS.md's runtime-string rule applies
to them: 「Runtime strings — refusal prose, prescriptions, anything an
author is shown — carry no tracker number (`pnpm check:doc-authoring`):
the lesson goes into the text.」 Form **D** of ruling C+D on the parent
card (comment `5749154545`, 「同意」) sets the shape: the lesson in words,
and no number, dead or alive.

This stage rewrites the busiest entry family, the five `engine-*`
entries: **67 sites → 0**. Each site now says what the cited ruling,
measurement or fix decided. ADR ids stay, because an ADR lives in this
repository. `registry.ts`, `spec-changes.json` and
`docs/protocol-upgrade-guide.md` are regenerated from the entries
(`gen:migration-registry`, `gen:spec-changes`, `gen:upgrade-guide`),
never hand-edited. One new pin holds the printed output tracker-free.

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

**Instrument.** A TypeScript-AST walk over every
`packages/spec/src/migrations/entries/**/*.ts`. For each `entry` object
literal it evaluates the string value of `replacement`, `reason` and
`acceptanceCriteria` (string literals joined with `+`), then counts `#`
followed by 4 or 5 digits at a word boundary. **Tree:**
`objectstack-ai/objectstack` at `443b2f4fdc` (this branch's base).

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

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

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

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

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

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

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

## Stage 1 = the `engine-` family

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

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

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

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

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

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

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

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

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

## Ablation — the pin can fail

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

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

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

## Acceptance notes

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

## Line budget

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

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

---------

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Data query: a dotted projection entry silently WIDENS the response — fields:["name","account.name"] returns every field

2 participants