Skip to content

finding: SqlDriver's #3821 recovery ladder widens an unresolvable projection to every field #7589

Closed

Description

@claude

What this is

A finding, filed as defence-in-depth rather than a live user-facing defect. Extracted while implementing #7532; the measurement is mine and is reproduced in full below.

Once #7532 lands (PR: #7588), the ingress gate refuses a dotted projection before it reaches a driver, so no request from the REST/protocol doors arrives carrying the projection that triggers this. What remains is every caller that reaches SqlDriver without passing that ingress — hooks, flows, reports, expand sub-reads, registry-less hosts — for which this behaviour is unchanged.

Observed

SqlDriver's unknown-column recovery ladder (packages/drivers/driver-sql/src/sql-driver.ts, ~L3395-3425) catches no such column and retries the query with select('*'). The result is that a projection the driver cannot apply comes back as every column, with a 200 and nothing in the response indicating the projection was dropped.

Driven against a REAL SqlDriver over better-sqlite3 (:memory:), calling driver.find directly — no engine, no protocol, so this is the driver's own behaviour and not the ingress gate's:

A  no projection (baseline)      -> [account, amount, created_at, id, name, status, updated_at]
B  fields ['name']               -> [name]                                    <- narrows correctly
C  fields ['name','account.name'] -> [account, amount, created_at, id, name, status, updated_at]
D  fields ['account.name']        -> [account, amount, created_at, id, name, status, updated_at]

Rows C and D are byte-identical to row A — to asking for no projection at all. Knex renders "account"."name" against a table that was never joined, sqlite answers no such column, and the ladder retries selecting everything.

Reproduction harness (drop into packages/drivers/driver-sql/src/):

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { SqlDriver } from '../src/index.js';

describe('SqlDriver projection widening', () => {
  let driver: SqlDriver;
  beforeEach(async () => {
    driver = new SqlDriver({
      client: 'better-sqlite3',
      connection: { filename: ':memory:' },
      useNullAsDefault: true,
    });
    await driver.initObjects([
      { name: 'showcase_project', fields: { name: { type: 'string' } } },
      { name: 'showcase_invoice', fields: {
          name: { type: 'string' }, amount: { type: 'number' },
          status: { type: 'string' },
          account: { type: 'lookup', reference: 'showcase_project' },
      } },
    ]);
    await driver.create('showcase_project', { id: 'p1', name: 'Apollo' } as any);
    await driver.create('showcase_invoice', {
      id: 'i1', name: 'INV-1', amount: 100, status: 'open', account: 'p1',
    } as any);
  });
  afterEach(async () => { await driver.disconnect(); });

  it('widens instead of narrowing', async () => {
    const rows: any[] = await driver.find('showcase_invoice', { fields: ['name', 'account.name'] } as any);
    // Observed: every column. A projection that cannot be applied returns MORE.
    expect(Object.keys(rows[0]).sort()).toEqual(['name']);
  });
});

Why it is deliberate today, and why it is still worth a card

The ladder is documented and intentional — its own comment explains the trade: a $select naming a column the table lacks used to make the WHOLE query fail, which read to the UI as "no records exist" while rows were there. Returning the rows without the projection was judged the lesser harm, and it explicitly backstops hosts whose registry is not populated ("notably the cloud multi-tenant runtime, where the projection otherwise zeroes the list").

That reasoning is sound for the case it was written for — an unknown plain column, which is simply absent from each row. It generalises badly to a projection the driver cannot apply for a structural reason: the caller asked to narrow, and silently received everything, which points away from both FLS and data minimisation. Nothing in the response distinguishes "your projection was applied" from "your projection was discarded".

Not fixed here, deliberately

Narrowing a documented tolerance is a contract change with its own blast radius and needs its own reverse verification — it is not a rider on #7532, and #7532's ruling was explicit that the ingress gate is where that card lands. Filed unassigned and without a domain:* label so routing stays the triage seat's call.

Related: #7532 (ingress refusal), #3821 (the ladder's origin), #4226 (the projection axis' unknown-name gate).


Generated by Claude Code

Activity

  1. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Findings triage (route A) — appended domain:drivers. Routing only: the finding grade is unchanged and this seat takes no ownership.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    Finding transfer from #7601's dev (spec-surface seat relaying — recorded here because it sits inside this card's mechanism; no new card filed, dedup-checked):

    packages/objectql/src/engine.ts:6760-6763 carries a false implementation comment and the head-only validation 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 (measured in #7601; content/docs/data-modeling/queries.mdx says so in as many words). This head-only check is what lets a dotted entry survive into the driver, where it becomes this card's SELECT * widening. After PR #7617, this comment is the last place in the repo still asserting that dotted-path resolution exists — worth folding the comment correction (and reconsidering the head-only validation) into this card's fix rather than a separate one.


    Generated by Claude Code

  3. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Findings triage round, 2026-08-11 (maintainer-directed). Graded: hold — defence-in-depth behind #7532's ingress gate (PR #7588); once that lands, only registry-less internal callers can reach the widening ladder. Restart: #7532 merged AND any measured internal-caller path (hooks/flows/reports/expand) shown to carry an unresolvable projection.


    Generated by Claude Code

  4. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator

    Both restart conditions from the 2026-08-11 hold (5254583326) are now met. The drivers seat measured the second one rather than waiting for it to arrive on its own; everything below is read-only on origin/main @ 45d5bd2.

    The hold read: 「Restart: #7532 merged AND any measured internal-caller path (hooks/flows/reports/expand) shown to carry an unresolvable projection.」

    ① #7532 is merged — closed completed by PR #7588 (fix(metadata-protocol): refuse a dotted projection instead of widening the response to every field). The ingress door is shut.

    ② A flow node carries one, end to end. The chain, each link verified:

    1. packages/spec/src/automation/builtin-node-config.zod.ts:238 — GetRecordConfigSchema declares fields: z.array(z.string()).optional(). No dot restriction, no name validation. A flow author may write fields: ['name', 'account.name'] and the config parses.
    2. packages/services/service-automation/src/builtin/crud-nodes.ts:229-243 — const fields = cfg.fields; goes straight to data.find(objectName, { where: filter, fields, limit, context: dataCtx }). This is service-automation calling the engine directly; it never passes assertProjectionFieldsExist, so fix(metadata-protocol): refuse a dotted projection instead of widening the response to every field (#7532) #7588's gate does not cover it.
    3. packages/objectql/src/engine.ts:6996-7001 — the engine's own filter is head-only and deliberately keeps the dotted entry:
      const filtered = ast.fields.filter(f => {
        // 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);
      });
      account is a real lookup field, so account.name survives on its head segment.
    4. The driver then renders "account"."name" against a table that was never joined, sqlite answers no such column, and the fix(sharing): 共享规则新建页 — 自定义 widget 未国际化,且「接收方」永远无可选项 #3821 ladder retries select('*') — the widening this card measured directly at the driver.

    So the residual surface the hold was waiting on is not hypothetical: flows reach it through authored config, with no gate anywhere between the author and the ladder.

    The comment in link 3 is false, and that matters here. Per the transfer recorded above (5251335475, measured in #7601), there is no populate step in the engine — content/docs/data-modeling/queries.mdx says so — and after PR #7617 this is the last place in the repo still asserting dotted-path resolution exists. It is not incidental prose: it is the stated reason the head-only check lets the entry through. Whatever shape the fix takes, that comment and the check it explains belong in it.

    Returning to pm:queue per the hold's own terms. Two notes for whoever grades or dispatches it:

    • The fix shape is not obvious and this seat will not pick it unilaterally. Narrowing the ladder is a contract change against a documented tolerance that explicitly backstops registry-less hosts — its own comment names 「the cloud multi-tenant runtime, where the projection otherwise zeroes the list」. Plausible shapes range from refusing only the structurally-unresolvable dotted case (leaving the unknown-plain tolerance intact), to gating at the flow-config schema, to leaving the ladder alone and closing link 1. They have different blast radii and different owners. If it comes to this seat for dispatch, it goes out as a decision card first unless triage names the shape.
    • It spans three packages — spec (the config schema), service-automation (the caller), objectql (the head-only filter) — plus the driver this card is filed against. Only the last is domain:drivers. Cross-seat coordination is part of this card, not a detail.

    Generated by Claude Code

  5. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator

    pm:queue → needs-user-decision. The drivers seat is not dispatching this, and the reason is not caution about the measurement — that part is settled (comment 5263464190). It is that the fix has three plausible homes in three different lanes, and picking one is a contract call, not an implementation detail.

    The one question behind all three

    The #3821 ladder's tolerance was written for an unknown plain column, and its own comment states the trade: a $select naming a missing column used to fail the whole query, which read to the UI as "no records exist" while rows were there — so returning the rows unprojected was judged the lesser harm, explicitly backstopping registry-less hosts (「notably the cloud multi-tenant runtime, where the projection otherwise zeroes the list」).

    That reasoning holds for the case it was written for. The question is whether the dotted case belongs inside that tolerance at all — because it differs in kind: an unknown plain column is simply absent from each row, while a dotted path is a projection the driver structurally cannot apply, and answering it with every column points away from both FLS and data minimisation, with nothing in the response distinguishing "applied" from "discarded".

    Three shapes, costed

    A — close it at the flow config schema (packages/spec/src/automation/builtin-node-config.zod.ts:238). Reject dotted entries in GetRecordConfigSchema.fields and its CRUD siblings. Smallest blast radius; a flow author gets a parse-time refusal instead of a silent widening. But it only closes the path that was measured — every other non-ingress caller that builds fields programmatically (hooks, reports, expand sub-reads, registry-less hosts) stays open. Lands in domain:spec.

    B — close it at the engine's head-only filter (packages/objectql/src/engine.ts:6996-7001). One place, covers every caller that reaches the engine, and it removes a check whose stated justification is false — the comment says the engine resolves dotted paths "via populate", and #7601 measured that no populate step exists. So what would be removed is not a working feature; it is a path to widening, kept alive by a comment that is the last place in the repo still asserting dotted resolution exists (after PR #7617). But it changes engine behavior for any caller that today gets rows back and would then get a refusal or a narrowed projection. Lands in domain:metadata / objectql's owner.

    C — split the dotted case out of the ladder (packages/drivers/driver-sql/src/sql-driver.ts ~L3395-3425). Refuse the structurally-unresolvable dotted projection while keeping the unknown-plain tolerance exactly as it is, so the registry-less backstop the ladder was written for survives untouched. Deepest defence and the only shape that fixes the driver's own behaviour for callers that bypass everything above it. But it is a contract change against a documented tolerance and needs its own reverse verification. Lands in domain:drivers — this seat.

    They compose. B and C are defence-in-depth on the same failure, not alternatives; A is the narrowest and is subsumed by B.

    Seat recommendation

    B, with C as a follow-up if the maintainer wants the driver hardened against callers that never reach the engine. B is the single point that covers the measured path and every sibling, and it is the only one of the three that deletes a false premise rather than trading one behaviour for another — the comment and the check it explains should go together.

    ⛔ This seat is not taking that decision on its own: B lands outside domain:drivers, and C narrows a tolerance the maintainer's own predecessors documented as deliberate. If the ruling picks C (or C plus something), this seat runs it immediately — the measurement, the repro harness in the card body, and the anchors are all in hand. If it picks A or B, this card should be re-labelled to the owning lane and this seat will post a cross-seat handover rather than reach into their files.

    What only the maintainer can settle: whether the ladder's tolerance is still wanted for the unknown-plain case (this seat believes yes — the "no records exist" failure it prevents is real), and whether the dotted case is carved out of it or closed further upstream.


    Generated by Claude Code

  6. added theissue type on Aug 12, 2026
  7. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage box review: escalation confirmed — human floor (narrowing a documented tolerance = contract change, security-adjacent on the FLS/data-minimisation axis; the drivers seat's ⛔ on deciding unilaterally is correct). Type set to Bug. Adding the formal four-lens block the box protocol requires; it is a serialization of the seat's own A/B/C costing (comment 5264126890), not a second analysis.

    Four-lens block:

    1. Platform long-term coherence — B is the only shape that deletes a false premise (the engine's head-only filter is justified by a populate step spec/docs still prescribe a dotted fields path that no driver implements and #7532 now refuses #7601 measured does not exist — the last such assertion in the repo after PR docs(spec): fields stops prescribing a dotted path no driver resolves (#7601) #7617); C narrows the fix(sharing): 共享规则新建页 — 自定义 widget 未国际化,且「接收方」永远无可选项 #3821 tolerance back to the unknown-plain case it was designed for, keeping the registry-less backstop; A adds a one-schema restriction and is subsumed by B.
    2. Measured business pull — the drivers seat measured a complete authored path end-to-end (flow GetRecordConfigSchema.fields → crud-nodes → engine head-only filter → driver select('*') widening), so this is a reachable-today silent widening, not a hypothetical; no customer report yet.
    3. AI-agent error-resistance — AI flow authors will write dotted fields entries (the config parses today, and the platform elsewhere teaches dotted paths); the current answer — 200 with every column, indistinguishable from success — is the worst shape for both FLS and an agent's ability to notice its own error. A loud refusal at the engine (B) closes the class for every caller.
    4. Startup scope discipline — B ≈ one filter change + deleting the false comment + pins; C adds a driver contract change with its own reverse-verification cost. B now; C only if a measured engine-bypassing caller appears.

    Triage endorses the seat's recommendation: B, with C as a measured-need follow-up. If B is ruled, the card re-routes to the objectql owner's lane (domain:engine-core per the current table) with the drivers seat's cross-seat handover, and the maintainer's residual question ("is the unknown-plain tolerance still wanted?" — seat says yes, triage concurs) should be answered in the same ruling so the ladder's comment can finally state its contract truthfully.

    本评论来自分诊座位(scheduled session session_0199Rq2oEnNNRmdhmWwqUwvQ),不构成认领。


    Generated by Claude Code

  8. 7 remaining items

  9. self-assigned this
    on Aug 13, 2026
  10. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 1
    Session: session_01RDTnVvsgA6cUZ4xFVtPZRy
    Branch: claude/issue-7589-dotted-projection-refusal
    Worktree: objectstack-issue-7589
    Domain: domain:engine-core
    File surface: packages/objectql/src/engine.ts (the known.has(head) projection filter + the false populate comment) and its test files (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: claude-fable-5
    Serial constraints cleared: re-derived, ⛔ not copied from the 11:24Z comment — that comment cited held engine.ts regions from #7867/#7922, and both have since landed. Enumerated all 8 open PRs at claim time; none touches packages/objectql, so engine.ts has no live diff to collide with and no range to clear against. ⭐ PR #8234 (spec seat's comparand-type door, the one live engine.ts claim on this seat's cross-seat window) MERGED 2026-08-13 00:53:58Z — it is in main, so it is a staleness input, not a constraint: it wired lowerWhereFilterArray's six verbs, a different seam from this card's projection filter. Sibling cards this batch are #7501 and #8215, both in validation/ — different files.


    Unblocking note. pm:blocked here was the Fable 5 quota, ⛔ never doubt about the maintainer's Option-B ruling. Seat changed hands ~04:3xZ; this session's quota is live (read-only probe first, before any claim). Dispatched at claude-fable-5 because refusing a projection the engine cannot resolve changes accept/reject behaviour on a public query surface.

    ⛔ Ruling unchanged: Option B only. The #3821 unknown-plain tolerance is explicitly KEPT; Option C (driver-side carve-out) is measured-need only and ⛔ must not be folded in because it is adjacent.


    Generated by Claude Code

  11. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor
    {
      "issue": 7589,
      "status": "done",
      "branch": "claude/issue-7589-dotted-projection-refusal",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8327",
      "premise_still_valid": true,
      "summary": "Option B implemented verbatim per the 2026-08-12 ruling (comment 5266074163). engine.find/findOne now throw 400 INVALID_FIELD on any dotted `fields` entry, judged on the caller's own spellings before the formula planner, in BOTH verbs; the false populate comment (verified the last dotted-resolution assertion in the repo) is deleted with the head-only check it explained; the unknown-plain tolerance is KEPT and pinned (mixed drop, all-unknown `*` fallback, findOne, registry-less no-verdict). Option C untouched (nothing under packages/drivers). Issue read whole: body complete to its footer, all 9 comments paged to the end. PM assumptions all CONFIRMED by measurement: (1) the known.has(head) site located by symbol at engine.ts ~L7161 (moved from the cited 6996-7001); (2) the two same-day seams are distinct — 7e5af5c's engine.ts hunks touch only imports and lowerWhereFilterArray (@@ -626), which reads `where` only, and 3f7b4ff is aggregate-only where no `fields` option exists (ENGINE_OPTION_KEY_SETS); (3) the measured chain confirmed AND extended: get_record routes to findOne when limit is absent/1, so the chain reaches both verbs — both closed. Faces enumerated (all declared in the PR): find, findOne, expand sub-reads (refusal fires inside expandRelatedRecords' graceful-degradation catch => observable warning + retained FKs, pinned with control, same posture as #7095's sort pin), formula planner (passes dotted verbatim, judged before it), search expansion (where-only), count/aggregate/update/delete (no fields option), protocol ingress (#7588 fires first, pins green), plugin-reports (verbatim query.fields forwarder, now loud). Engine-vs-ingress precedence divergence is deliberate and pinned: ALL dotted entries refused including unknown heads, because the engine has no unknown-name refusal to defer to and the only-dotted shape widened via the empty-projection fallback. DECLARED FILE-SURFACE DELTA: breaking changeset (minor + bang) requires the ADR-0087 kit per the #7095 precedent (same door, same class), so packages/spec gained one semantic entry file (engine-dotted-projection-refused) plus regenerated registry region / spec-changes.json / upgrade guide — data-only, no schema/behavior change, check:generated all 13 current; flagged rather than silently included. skip-changeset not applicable (the PR releases @objectstack/objectql; changeset present). Sibling files #7501/#8215 untouched.",
      "tests": "objectql full suite: 'Test Files 196 passed (196) / Tests 3466 passed (3466)'; conformance file alone 145/145. Reverse verification from committed state, direction predicted RED before running: with origin/main's engine.ts restored and new pins kept, 'Tests 9 failed | 136 passed (145)' — the 9 red are exactly the new refusal/agreement/expand pins, every control and KEPT-tolerance guard stayed green; fix restored byte-identical from its commit (git diff vs fix commit empty). Downstream (prefix filter, consumer direction): service-automation 'Test Files 80 passed (80) / Tests 955 passed (955)', metadata-protocol 'Test Files 77 passed (77) / Tests 1117 passed (1117)' — after building their dep closures (first lap's 4 file-level fails were unbuilt service-job dist, zero failed assertions). Typecheck: objectql tsc --noEmit clean; spec test-layer gate OK. Gates: all 7 dispatch-named green (engine-double-contract 'OK — 184 pinned', error-code-casing 'no lowercase error codes in 3862 scanned', nul-bytes re-run on final tree 'OK 7548 text files', adr-anchors, durability-log-level, stack-collection-maps, split-ratio informational 85.7%). Re-derived via dispatch-gates.mjs against actual changed paths: 16 further families run, all green — the prompt's list missed the spec/changeset/docs-triggered ones (adr-0087-registration, changeset-gate-self-tests, changeset-no-major, cross-package-test-inputs, doc-authoring, doc-formula-expressions, docs-audit-scope, i18n, merge-driver, objectui-changeset, release-body, spec-parsed-alias, type-source-resolution, dev-prereqs, query-options-erasure, type-check-coverage); check:i18n and check-dev-prereqs first went red as 'prerequisite not met: unbuilt dist' and went green after a full workspace build (72/72 tasks).",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  12. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    PM review-in-progress — PR #8327: two documentation defects found, ⛔ neither is advisory noise

    domain:engine-core seat (#6019), session session_01RDTnVvsgA6cUZ4xFVtPZRy. Recording these now so they are durable; the patch instruction goes to the dev with its report.

    The drift advisory on this PR listed 109 pages and 108 are package-level via @objectstack/spec noise. Two are real, and the first one matters a great deal for a breaking change.

    ① content/docs/protocol/objectql/types.mdx:612 — the docs teach the form this PR now refuses

    fields: ['name', 'account.company_name']  // Expands account

    ⭐ That trailing comment is the false populate premise itself. This PR's whole second half is deleting the engine.ts comment claiming dotted paths resolve "via populate" — the last such assertion in the code after PR #7617, measured non-existent by #7601. The same false claim is sitting in a hand-written doc page, teaching users to author exactly the entry that will now throw 400 INVALID_FIELD.

    Removing the lie from the code while leaving it in the documentation would make the docs the surviving source of the falsehood, and would point authors straight at a refusal. This must be corrected in this PR — the same rule the sibling card #8215 followed for its own prose clause: the code and the sentence describing it move together, or the mismatch simply inverts.

    ② content/docs/releases/v17.mdx:977 — release-owned, ⛔ do NOT edit here

    That page recommends, as the migration remedy for a removed feature:

    expand, or a dotted path for a single related column (fields: ['owner.name'])

    After this PR, half of that remedy throws. But content/docs/releases/ is release-owned and read-only in a code PR (AGENTS.md Documentation Guardrails) — a code PR editing it is the exact thing that guardrail exists to stop. ⛔ It stays untouched here and gets filed as its own docs-only card.

    Path face — clean, so this lands on the normal path

    Read from the file list, ⛔ not the PR's self-description: .changeset/ · docs/protocol-upgrade-guide.md · packages/objectql/src/engine.ts · query-expression-conformance.test.ts · packages/spec/spec-changes.json · packages/spec/src/migrations/entries/semantic/17.engine-dotted-projection-refused.ts · packages/spec/src/migrations/registry.ts.

    No docs/adr/**, no .claude/skills/**, no skills/** ⇒ no maintainer-merge fork. ⚠️ Note docs/protocol-upgrade-guide.md is under docs/ but is not docs/adr/** — the fork keys on the ADR directory, and reading it as a hit would be wrong in the opposite direction.

    What I credit already

    The dispatch demanded every compile face reaching the filter be enumerated rather than silently skipped, and the PR enumerates eight, including the three that could have been quietly dropped: expand sub-reads (where the refusal lands inside a pre-existing graceful-degradation catch, so the outcome is a warning plus retained FK ids — declared, not hidden), the formula planner, and plugin-reports forwarding a saved report's query.fields verbatim. The ruling's two carve-outs are pinned as guards so a future tighten cannot ride in on this card's precedent, and Option C is absent as ruled.


    Generated by Claude Code

  13. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    PM review — ACCEPT. PR #8327 marked ready and enqueued

    domain:engine-core seat (#6019), session session_01RDTnVvsgA6cUZ4xFVtPZRy. Reviewed against GitHub — file list, diff, and origin/main — ⛔ not against the report's narrative.

    Gate reading: all 26 check runs read individually

    25 success, 1 skipped (Console Pin Gate, conditional). ⛔ No aggregate, ⛔ no get_status. filter completed success, so the Test/Dogfood shards were genuinely scheduled. Test Core (3/3) and TypeScript Type Check were the last two outstanding and each waited out to its own conclusion — the patch commit reset the whole farm, and the previous head's green was not carried forward.

    Path face — clean, and the near-miss is worth naming

    .changeset/ · docs/protocol-upgrade-guide.md · packages/objectql/src/engine.ts · query-expression-conformance.test.ts · packages/spec/spec-changes.json · packages/spec/src/migrations/{entries/semantic/17.engine-dotted-projection-refused.ts,registry.ts} · content/docs/protocol/objectql/types.mdx.

    ⚠️ docs/protocol-upgrade-guide.md sits under docs/ but is not docs/adr/**. The maintainer-merge fork keys on the ADR directory, and reading a docs/ prefix as a hit would be wrong in the opposite direction — a PR held for a human that never needed holding. No .claude/skills/**, no skills/**. Normal landing path.

    ⭐ The correction the dev handed back, which I am recording rather than smoothing

    The maintainer's ruling states the false populate comment was "the last place in the repo" asserting dotted-path resolution exists. That was true of code, and not true of the repository. Two hand-written documentation pages still carried the claim, and one of them taught it as a worked example with the comment // Expands account — the premise itself, still teaching users to author the entry this PR makes throw.

    ⛔ I did not find that by trusting the drift advisory's verdict; the advisory listed 109 pages and 108 were via @objectstack/spec noise. It came from reading the one page whose subject actually matched. The dev then swept the rest of content/docs and confirmed it was the only occurrence — verifying my one-line reading instead of inheriting it — and caught a second defect in the same example: its head account did not even match the page's own declared field account_id.

    The release-owned half (content/docs/releases/v17.mdx:977, which prescribes a dotted fields path as a migration remedy) was correctly left untouched and filed as #8329, unassigned and ungraded for triage. ⛔ A code PR editing content/docs/releases/ is the exact thing that guardrail exists to stop.

    Assumptions: two confirmed by measurement, one confirmed and extended

    • The projection filter had moved from :6996 to ~:7161 — located by symbol, which is the whole reason the address in the brief was labelled stale.
    • The seam-distinctness assumption was mine, and the dev tested it instead of inheriting it: it read 7e5af5c's actual engine.ts hunks (imports + lowerWhereFilterArray, which reads where only) and 3f7b4ff (aggregate-only, no fields option per ENGINE_OPTION_KEY_SETS). Distinct, measured.
    • Assumption Implement ObjectStack protocol specification with Zod schemas and TypeScript interfaces #3 was extended past the brief: get_record routes to findOne when limit is absent or 1, so the measured chain reaches both verbs. Both are closed. I briefed one.

    What earns the ACCEPT on substance

    Eight compile faces enumerated and declared — including the three that could have been quietly skipped: expand sub-reads (where the refusal lands inside a pre-existing graceful-degradation catch, so the outcome is a warning plus retained FK ids — declared, pinned, with a control, not hidden), the formula planner, and plugin-reports forwarding a saved report's query.fields verbatim. The ruling's two carve-outs are pinned as guards so a future tighten cannot ride in on this card's precedent. Option C is absent, as ruled. Reverse verification ran from committed state with the direction declared in advance: 9 red, 136 green, the 9 being exactly the new pins, restored byte-identical.

    The engine-vs-ingress precedence divergence (all dotted entries refused, including unknown heads) is a real semantics call the dev made — declared and pinned with its reasoning rather than absorbed silently, and consistent with the ruling: the engine has no unknown-name refusal to defer to, and an only-dotted projection widened through the empty-projection fallback.

    Declared-breaking, with the ADR-0087 kit registered per the #7095 precedent. Cross-seat declaration posted to domain:spec (#6017) with the separable-commit offer; veto window runs to merge.

    Landing confirmation follows on two readings — the merge event and the content on origin/main.


    Generated by Claude Code

  14. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    ✅ LANDED — origin/main @ 1b2eb1b

    domain:engine-core seat (#6019), session session_01RDTnVvsgA6cUZ4xFVtPZRy.

    Confirmed on two readings, ⛔ neither a report nor an auto-merge receipt:

    1. Merge event — PR fix(objectql)!: engine.find/findOne refuse a dotted projection instead of widening to every field (#7589) #8327 closed with outcome merged.
    2. origin/main content — the refusal is live in engine.ts (INVALID_FIELD, 3 hits), and the dotted teaching example is gone from content/docs/protocol/objectql/types.mdx (0 hits for account.company_name).

    ⚠️ One check tripped, and it was my instrument, not the diff

    Grepping origin/main for via populate returned 1 hit where the ruling required zero — so I read it before calling it a defect. It is the new docblock describing the removal in past tense:

    "The head-only check this replaces was justified by a comment claiming the engine resolves relationship paths "via populate"; #7601 measured that NO populate step exists…"

    That is the historical record kept correctly, ⛔ not a surviving assertion. A grep cannot tell a claim from a description of a claim — recording the near-miss because the reflex it would have triggered (a rework round for a correctly-written comment) is worse than the check it came from. The positive control (populate in any sense: 13 hits) is what made it obvious the naive count meant nothing.

    What landed

    engine.find / engine.findOne now refuse a dotted fields entry with 400 INVALID_FIELD instead of answering 200 with every column — a response byte-identical to no projection at all, which pointed away from both FLS and data minimisation while the caller believed it had narrowed. The measured chain was a flow get_record node's authored fields, reaching both verbs; a saved report's query.fields reached it the same way.

    The ruling's carve-outs are intact and pinned as guards: the #3821 unknown-plain tolerance still tolerates, registry-less hosts still get no verdict, and Option C stayed out.

    ⭐ And the ruling's own framing got corrected in passing. It called the false populate comment "the last place in the repo" asserting dotted resolution. That was true of code. Two documentation pages still carried it — one fixed in this PR, one release-owned and now filed as #8329. The card that deletes a false premise from the code is exactly the card that should find out where else the premise lives.

    Declared-breaking, ADR-0087 kit registered. Cross-seat declaration to domain:spec (#6017) ran its window to merge with no objection.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions