Skip to content

objectql: a formula field in a fields projection widens it to every column and the rows are never trimmed back — get_record sends owner, org and audit columns to an external endpoint #22300

Description

@objectstack-fleet

Filing gate: ① product defect with reach measured. Class (b): data leaves through a node whose contract says it is not read. reach: a flow's outbound HTTP delivery, measured once with a wrong result on @objectstack/* 17.7.0 through hotcrm (origin/main 99d290ad). The dev of objectstack-ai/hotcrm#1595 (PR objectstack-ai/hotcrm#2013, session session_012zh91QzFgePbkmuHnugLN3) found them; the repo:hotcrm seat located them.

Who acts on it: the objectstack triage seat routes it; the fix lands in packages/objectql/src/engine.ts. Split on the maintainer's word (one card per defect): the cascade-order defect is now #22305 and the insert-side readonly defect is #22306. ⛔ Not a claim. hotcrm WAITs for each fix (hotcrm AGENTS.md §2) and builds no workaround.

A formula field widens a fields projection to every column, and the rows are never trimmed back

  • Contract: get_record's config.fields says "only these fields are read" (packages/spec/src/automation/builtin-node-config.zod.ts:408-410 at 17.7.0). The executor passes fields straight to data.find (service-automation crud-nodes.ts:483-496).
  • What happens:
    • planFormulaProjection (engine.ts:1568-1580, still the same on main 3ae59661) turns a requested projection that contains a formula field into every non-formula field, so CEL's record.<field> sees the full row.
    • It is applied at :11993 (find) and :12296 (findOne).
    • After applyFormulaPlan (:12070-12100) the rows are never cut back to the requested fields.
  • Measured:
    • hotcrm's billing hand-off reads line items with fields = LINE_ITEM_FIELDS (src/sales/flows/_billing-endpoint.ts:113-123). That list includes total_price, a Field.formula.
    • The sys_http_delivery payload_json.line_items posted to the external billing endpoint carry organization_id, created_by, updated_by, owner_id, owning_business_unit_id, created_at, updated_at and the parent crm_opportunity id, none of which were requested.
  • Fix site: trim each row back to the requested fields (plus whatever the caller asked for, such as id) once the formulas are evaluated, in both find and findOne.

Duplicate check

gh search is refused in this container (GraphQL and REST search answer 403), so all 9,565 objectstack issues (/issues?state=all, PRs excluded) were listed and matched case-insensitively: planFormulaProjection 4 (#7095 and #6994 are ORDER BY; #4196, #1530); formula projection all fields 20; get_record projection 2 (#21519 and #21623 are stored metadata). None is this defect.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, bug · security · priority:p1 · domain:engine · area:records · pm:queue. Three engine fixes; item 1 first, graded on its worst reading

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-08T13:52Z. ⛔ Not a claim, ⛔ not a dispatch.

    Triage: lands in packages/objectql/src/engine.ts (the formula projection plan, the cascade delete walk, the insert hook order) ⇒ domain:engine; rationale: packages/objectql is that lane's.

    • Why p1 and security: item 1 widens a declared projection and never trims the rows back, so data a node's contract says is not read leaves the system through an outbound delivery. Graded on its worst reading under the may-leak-data exception: whether the widened read also carries fields the caller's field permission withholds is unmeasured. Measure that first. If only columns the caller may read leave, keep p1 for the contract breach and drop security. ⛔ Follow-ups stay abstract (classes, positions, files and functions).
    • Item 1 direction: after the formula plan evaluates, cut each row back to the requested projection. The formula still sees the full row; the caller does not.
    • Item 2 (p2 on its own): collect the cascade set before evaluating restricts, so a sibling in the same cascade never restricts another.
    • Item 3 (p2 on its own): withhold readonly keys from beforeInsert as update already does (objectstack#16344's rule, insert-side twin).
    • Shape: three PRs are fine, item 1 first. hotcrm waits on each, by its rule.
  2. changed the title [-]objectql: a formula field in a `fields` projection widens it to every column and rows are never trimmed back (get_record sends owner/org/audit columns out); cascade delete trips its own restrict; insert shows hooks readonly keys it then strips[/-] [+]objectql: a formula field in a `fields` projection widens it to every column and the rows are never trimmed back — get_record sends owner, org and audit columns to an external endpoint[/+] on Oct 8, 2026
  3. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    This amends my grade 6061395792: items 2 and 3 are now their own cards, #22305 (cascade restrict) and #22306 (insert hook readonly order), split on the maintainer's word. Both are graded p2, domain:engine. This card is item 1 only (the formula projection widening), and its grade stands: p1, security on the worst reading, measure first.

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-08T14:56Z. ⛔ Not a claim, ⛔ not a dispatch.

  4. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 64 · 2026-10-08T16:40Z
    Session: session_01EUBvqtauTDmHi2ZgY759p2
    Account: os-litant (the seat's linked user, as GET /user answers it; always the card's assignee)
    Branch: claude/issue-22300-formula-projection-trim
    Worktree: objectstack-issue-22300
    Domain: domain:engine
    Seat: domain:engine#1
    Provenance:

  5. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22300,
    "status": "done",
    "branch": "claude/issue-22300-formula-projection-trim",
    "pr": "#22337",
    "session": "session_01EUBvqtauTDmHi2ZgY759p2 — subagent run, so this is the parent PM session's id (dispatch order and the container's CLAUDE_CODE_REMOTE_SESSION_ID agree)",
    "premise_still_valid": true,
    "summary": "M1 reproduced on base f2626c7: planFormulaProjection widens a projection that names a formula field to every stored column plus id, and find/findOne return every column. Measured at three places: the engine (12 unrequested keys), a flow get_record on driver-sql (11 unrequested keys, both branches, both runAs) and POST /api/v1/data/:object/query with fields (13 keys). Fix: planFormulaProjection now also returns widened, the columns it added beyond the caller's names (id included when not named). New module-private withoutFormulaWidening / rowsWithoutFormulaWidening remove exactly those columns at the return of find and findOne, after executeWithMiddleware, which is the last internal consumer. The formula pass, expand, file references, afterFind hooks, the secret mask, the __search strip and the middleware post-phase (FLS mask, audit redactions) still read the widened row. A hook-derived key survives the cut. Rows are copied, never mutated. M2: the FLS result mask (SecurityPlugin.maskOperationResult → FieldMasker.maskResults, middleware step 4) runs in the middleware post-phase, after the widening and after applyFormulaPlan. Measured on base with the real SecurityPlugin over driver-sql: a member's withheld, unrequested field never left (REST, find, findOne). The formula read carried exactly the no-projection column set. So only columns the caller may read leave; the seat can ask triage to drop security, and p1 stays for the contract breach. M3: on driver-sql, a projection without a formula returns exactly the named columns, with no id unless named, and the engine adds nothing. The pins assert formula row == non-formula row + formula value on driver-sql and on a driver-sql-shaped double. driver-memory and driver-mongodb add id to every projection at the driver layer. Under this cut, a formula projection on those drivers omits that id (measured on driver-memory); see open_questions. M5: only find and findOne pass a caller projection to planFormulaProjection; count, aggregate and the write pre-image reads plan none. Clause-②: no, measured — the built objectql .d.ts files are byte-identical between fe98cc6 and 99ffeb8.",
    "tests": "objectql full suite, pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 at aaa5e4d: 'Test Files 386 passed (386) / Tests 7600 passed (7600)'. --project repo: 'Test Files 1 passed (1) / Tests 5 passed (5)'. Only the new test file changed after aaa5e4d, and it was re-run at 99ffeb8: 'Tests 12 passed (12)'. objectql typecheck at 99ffeb8: exit 0, 'check:test-typecheck: OK … 40 file(s) / 234 error(s) / 65 pinned signature(s)' (unchanged); the new test file is in the tsconfig.test.json program (--listFilesOnly count 1). service-automation, pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 at 99ffeb8: 'Test Files 177 passed (177) / Tests 2165 passed (2165)'. Its typecheck: exit 0, test-layer debt 0, door pin in the program. rest at 99ffeb8: --project local 'Test Files 263 passed (263) / Tests 4951 passed | 326 skipped (5277)'; --project repo 'Test Files 5 passed (5) / Tests 191 passed | 1 skipped (192)'. M1 on base f2626c7: engine pin 'Tests 8 failed | 4 passed (12)' (controls green); door pin 'Tests 8 failed (8)'. Reverse verification at 99ffeb8, via scripts/ablation-replace.mjs WRAP with a trap restore. Mutation: withoutFormulaWidening's guard set to always return the row ('anchor 1 → 0, blob 42aff651c70c → 45700267e608'). Engine pin (src): 'Tests 8 failed | 4 passed (12)'. The objectql build emitted JS carrying the marker in 4 built files ('ablation-dist-preflight' exit 0); its DTS step failed with TS18048 because the mutation removed a narrowing, and the consumed JS does not depend on that step. Door pin (dist): 'Tests 8 failed (8)'. Restore: 'blob == HEAD (42aff651c70c) and git diff HEAD is empty', rebuild exit 0, 'marker absent from all 14 built files', 'working tree clean against HEAD'; then engine pin 12 passed and door pin 8 passed. Direction: red, as expected. Gates: node scripts/pm/dispatch-gates.mjs --commands at 99ffeb8 derived 69 commands; 69 ran, each exit 0 (list in gates). --ran reconciliation: 'Run reconciliation — 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN' (a derived zero, from the recorded exit codes). Earlier, check:objectql-double-limit was red on the first test double (its probe could not drive it); the double was reshaped in 99ffeb8 and the gate is now green. Narrowed lint at 99ffeb8: npx eslint --no-inline-config --format json on the 3 touched .ts files returned 3 files, 0 errors, 0 warnings, exit 0. The evidence: (1) eslint.config.mjs's /*.{ts,…} and packages//*.{ts,…} blocks cover all three, and all three come back in the JSON with results; (2) the JSON count is 3; (3) eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules, its own comment), so this diff cannot change a verdict on an untouched file. Repo-wide pnpm lint is CI's. M2 one-off (rest scratch test, never committed): base formula projection keys=13, withheld field present=false; head keys=[name,total_price]. M3 one-off (driver-memory, built engine, head): fields [name] → name,id; [name,total_price] → name,total_price. Clause-② d.ts comparison, merge-base fe98cc6 vs head 99ffeb8: sha256 prefixes match for index.d.ts edb995cf7c2baa9f, core.d.ts 3b5e05652e32b56e and util-BuqCJOyg.d.ts 357f6c243d69e450. NOT MEASURED, left to CI: Test Core shards, Dogfood, Temporal Conformance, Build Core and the workspace type-check lanes. PR CI at report time: 13 check runs completed (all success or skipped), 18 in_progress.",
    "mcp_calls": "0 — no MCP GitHub tool was called",
    "api_writes": "3 REST writes, each sent as one repository_dispatch through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches; runs 37825462562, 37825569185 and the report's run): (1) pr_create POST /repos/objectstack-ai/objectstack/pulls → #22337, draft, 12370 bytes sent and stored identical; (2) assign POST /repos//issues/22337/assignees [os-litant], read back as a match; (3) comment POST /repos//issues/22300/comments, this os-dev-report. Plus 4 git pushes, which are not REST. No label write: neither the dispatch nor os-dev's skip-changeset rule names a label (a changeset ships). The labeler's documentation/size-m/tests/tooling labels were not written by this run. No card assignee or card label write.",
    "open_questions": [
    {
    "question": "Should the cut keep id when the caller did not name it? driver-memory and driver-mongodb add id to every projection at the driver layer (InMemoryDriver.projectFields, MongoDBDriver.buildFindOptions); the driver-sql family (driver-sql, driver-sqlite-wasm, local driver-turso) does not; no driver contract declares either. So no fixed engine rule makes 'formula row == the same projection without the formula + the formula value' hold on every driver.",
    "options": [
    "A (shipped in #22337): cut id like every other widened column, so a formula projection returns exactly the named columns plus the formula. Byte-equal on the driver-sql family (where the defect was measured); on driver-memory/mongodb a formula projection lacks the id their non-formula projections carry. Cost: a memory/mongo caller reading .id off a formula projection without naming id loses it (on base it got id and everything else).",
    "B: never cut id, treating the widening's identity column as always returned. Byte-equal on driver-memory/mongodb; on the SQL family a formula projection carries an id the same projection without the formula does not. Cost: one key wider than the ruling's 'requested projection' on the production SQL path.",
    "C: settle the driver-level rule (does a projection always return id?) in the driver contract, and have every driver follow it; then A and B coincide. A separate card; today nothing declares the rule."
    ],
    "recommendation": "A, as shipped. Business need: on the SQL family a caller already has to name id for a plain projection, so a portable caller loses nothing. Long-term soundness: A is the ruling's literal 'requested projection', and the real fix for the drift is C, not a per-engine compromise. AI-error-proofing: A teaches one rule, 'name id if you need it'. Startup focus: neither A nor B adds surface; C stays unfiled until a real memory/mongo consumer is burned."
    }
    ],
    "out_of_scope_findings": [
    "carrier: 承接者:无 · noted, not filed — driver projection id rule diverges: driver-memory and driver-mongodb add id to every projection, the driver-sql family does not, and no contract declares either. Measured on driver-memory at head; mongodb read from source. Recorded in PR #22337 Acceptance notes and in open_questions. Dedupe words: projection id driver; driver-memory projectFields id; mongodb projection id always",
    "carrier: 承接者:无 · noted, not filed — formula evaluation vs the field-level-security mask: applyFormulaPlan evaluates against the row before the FLS result mask (middleware post-phase), on every read with or without a projection. Whether a formula over a withheld field should be evaluated for that caller is unmeasured and unchanged by this PR. Recorded in PR #22337 Acceptance notes. Dedupe words: formula field-level security; formula withheld field; FieldMasker formula"
    ],
    "gates": [
    "node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
    "node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
    "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
    "node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
    "node scripts/check-ci-filter-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-corpus.mjs :: exit 0",
    "node scripts/check-dts-emitted.mjs --self-test :: exit 0",
    "node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
    "node scripts/check-empty-changeset.mjs --self-test :: exit 0",
    "node scripts/check-engine-split-ratio.mjs --days 90 :: exit 0",
    "node scripts/check-engine-split-ratio.mjs --self-test :: exit 0",
    "node scripts/check-issue-citations.mjs :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
    "node scripts/check-registry-log-declared.mjs :: exit 0",
    "node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
    "node scripts/check-system-context-census.mjs :: exit 0",
    "node scripts/check-system-context-census.mjs --self-test :: exit 0",
    "node scripts/check-tenant-audit-census.mjs :: exit 0",
    "node scripts/check-tenant-audit-census.mjs --self-test :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
    "node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
    "node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
    "node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
    "node scripts/release-pending-publish.mjs --self-test :: exit 0",
    "pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
    "pnpm check:changeset-gate-self-tests :: exit 0",
    "pnpm check:cross-package-test-inputs :: exit 0",
    "pnpm check:dispatcher-error-vocabulary :: exit 0",
    "pnpm check:doc-authoring :: exit 0",
    "pnpm check:driver-memory-census :: exit 0",
    "pnpm check:dts-closure :: exit 0",
    "pnpm check:dual-build-cjs-loads :: exit 0",
    "pnpm check:durability-log-level :: exit 0",
    "pnpm check:engine-double-contract :: exit 0",
    "pnpm check:gitlink-declared :: exit 0",
    "pnpm check:issue-citations :: exit 0",
    "pnpm check:lean-entry-closure :: exit 0",
    "pnpm check:logger-receiver-detach :: exit 0",
    "pnpm check:nul-bytes :: exit 0",
    "pnpm check:objectql-double-limit :: exit 0",
    "pnpm check:objectui-changeset :: exit 0",
    "pnpm check:org-identifier :: exit 0",
    "pnpm check:page-declaration-shape :: exit 0",
    "pnpm check:pm-changeset-deadline-census :: exit 0",
    "pnpm check:published-files :: exit 0",
    "pnpm check:query-options-erasure :: exit 0",
    "pnpm check:refd-timer-probe :: exit 0",
    "pnpm check:slot-lookup :: exit 0",
    "pnpm check:sourcemap-no-sources-content :: exit 0",
    "pnpm check:stack-collection-maps :: exit 0",
    "pnpm check:swallow-census-controls :: exit 0",
    "pnpm check:test-source-alias :: exit 0",
    "pnpm check:tier-file-adoption :: exit 0",
    "pnpm check:type-check-coverage :: exit 0",
    "pnpm check:type-check-debt :: exit 0",
    "pnpm check:watch-hint-literal :: exit 0",
    "pnpm check:where-matcher :: exit 0"
    ],
    "line_budget": {
    "files": 4,
    "additions": 443,
    "deletions": 4,
    "changed_lines": 447,
    "human_merge_threshold": 5000,
    "verdict": "under",
    "measured": "git diff --shortstat fe98cc6 99ffeb8"
    },
    "files_changed": [
    ".changeset/22300-formula-projection-trim.md",
    "packages/objectql/src/engine.ts",
    "packages/objectql/src/engine-formula-projection-trim.test.ts",
    "packages/services/service-automation/src/builtin/get-record-formula-projection.integration.test.ts"
    ],
    "deviations": [
    "Cross-lane file (the seat declares it): packages/services/service-automation/src/builtin/get-record-formula-projection.integration.test.ts (domain:services), the door pin. It only adds a test.",
    "Merged origin/main (fe98cc6: #22186 and #22197, both touching objectql) at aaa5e4d. Clean merge, both intents kept, delta vs main still exactly the 4 files. origin/main later moved to 28bff18 (packages/cli only); not merged, because it does not overlap.",
    "The full objectql suite ran at aaa5e4d, not at the final head 99ffeb8. The only later change is the new test file's double, re-run at head (12 passed), together with objectql typecheck and all 69 gates at head.",
    "The ablation's mutant build failed its DTS step (TS18048, a narrowing the mutation removed). The JS artifact the door pin consumes was emitted and carried the marker (preflight exit 0), so the reading stands.",
    "Two locked chains were sequenced with ';' (lock verdict 'batch-last-exit'). Each part's own exit code was echoed and is what is quoted above.",
    "M2 was measured once through a scratch REST test (real SecurityPlugin + driver-sql + RestServer), never committed; the door pin itself boots no SecurityPlugin.",
    "Attribution: the commits carry AGENTS.md's model-free trailer pair, and the PR body ends with AGENTS.md's session-URL footer; the harness reminder's model-named trailer and footer form were not used (AGENTS.md takes precedence).",
    "PR body edits after create: none. Should the seat want the measured M2 line relayed to triage, the PR body already carries it under 'M2'."
    ],
    "pr_body_full": {
    "body": "Fixes #22300\nClause-②: no\n\n## What changes\n\nplanFormulaProjection (packages/objectql/src/engine.ts) widens a fields projection that names a formula field to every stored column plus id, so the formula's CEL record.FIELD lookups see the full row. find and findOne never cut that widening back off their results. A projection that named a formula field therefore returned every column of the record.\n\n- planFormulaProjection now also returns widened: the columns it added beyond what the caller named. id is one of them when the caller did not name it.\n- New withoutFormulaWidening / rowsWithoutFormulaWidening remove exactly those columns from the result. find and findOne call them at their return, after executeWithMiddleware.\n- The driver read is unchanged. The formula still sees the full row.\n\n## Where the cut sits (M4), and what still reads the widened row\n\nEvery internal reader of the widened rows runs before the cut, and they run in this order:\n\n1. the formula pass (resolveFormulaPermissions, applyFormulaPlan), including the unevaluated-formula fault sink;\n2. expandRelatedRecords, which reads only the foreign-key keys named in expand;\n3. resolveFileReferences;\n4. the afterFind hooks;\n5. maskSecretFields / omitInternalFields and stripSearchCompanionFromRead;\n6. the middleware post-phase. This includes the field-level-security result mask, and the audit redactions that add their judged columns to the projection and remove them afterwards.\n\nCutting any earlier would starve one of these of a column it reads today. The cut removes the widened columns rather than keeping a list. A key an afterFind hook derives survives, and so does an expanded relation the caller named. Rows are copied, never mutated, in their own key order.\n\nPost-hoc sorting of formula fields is refused before planning (assertOrderByIsMaterializable), so it reads no row.\n\n## Measurements\n\n### M1: the widening, reproduced on main before the change\n\n- Engine: the new engine-formula-projection-trim.test.ts, at base f2626c71db: 8 failed and 4 passed of 12. The 4 that passed are the controls. Each failing case received 12 unrequested keys.\n- Door: a flow get_record on the real stack, driver-sql on better-sqlite3. That is get-record-formula-projection.integration.test.ts, at base f2626c71db: 8 failed of 8, covering both node branches and both runAs. Each served row carried 11 unrequested keys.\n- Also measured on the same base: the REST data read, POST /api/v1/data/:object/query with fields. It returns the same 13 keys for a formula projection. The same cut covers it.\n\n### M2: field-level security against the widening\n\nThe FLS result mask is SecurityPlugin.maskOperationResult → FieldMasker.maskResults. It is step 4 of the security middleware and runs in the middleware post-phase. That is after planFormulaProjection widened the driver read and after applyFormulaPlan ran, on the rows the read returns.\n\nMeasured on base f2626c71db with the real SecurityPlugin over driver-sql. A member's permission set withheld one field (readable: false) that the projection did not name. The read was POST /api/v1/data/:object/query plus direct find / findOne with the member context:\n\n| read (member) | keys returned on base | withheld field present |\n|---|---|---|\n| fields: [name, total_price] | 13: name, total_price, quantity, unit_price, note, id, organization_id, owner_id, owning_business_unit_id, created_by, updated_by, created_at, updated_at | no |\n| fields: [name] | name | no |\n| no projection | the same 13 | no |\n\nAnswer: only columns the caller may read leave. The widened read never carried the withheld field. It carried exactly the no-projection read's column set. That is the contract breach this card names, with no field-permission bypass. The seat may relay this to triage to drop security. A system-identity caller (for example a flow with runAs: system) has no field mask, so every column is readable to it. Its widened read is the same contract breach, not a permission bypass.\n\n### M3: what a projection without a formula returns\n\n- driver-sql, measured through the door pin's control: fields: [name, quantity] returns exactly name, quantity. There is no id unless it is named.\n- The engine adds no key to a projection. PLATFORM_PROVISIONED_COLUMNS only admits names in the unknown-plain-field filter.\n- So the cut restores exactly the named columns plus the formula value. The pins assert this as row equality: the formula read equals the same read without the formula, plus the formula value, on driver-sql and on the driver-sql-shaped double.\n- driver-memory and driver-mongodb behave differently, and that is not engine behaviour: each adds id to every projection at the driver layer. See the measured reading in Acceptance notes.\n\n### M5: other read paths\n\nplanFormulaProjection has four callers:\n\n- find and findOne pass the caller's projection.\n- hydrateWriteFormulas, the write-result hydration, passes no projection.\n- evaluateFormulaField passes one field and evaluates on a copy.\n\ncount, aggregate (its driver.find fallback included) and the write paths' pre-image reads never plan a formula projection.\n\n## Base vs head: keys returned per case\n\nEngine table, driver-sql-shaped double, find and findOne alike. The stored row also carries note, parent_id and the seven provisioned columns:\n\n| projection | base f2626c71db | head |\n|---|---|---|\n| [name, total_price] | 14 keys (every stored column + total_price) | name, total_price |\n| [id, owner_id, total_price] | 14 keys | id, owner_id, total_price |\n| [name, total_price] + an afterFind hook adding a key | 15 keys | name, total_price + the hook's key |\n| [name, quantity] (control) | name, quantity | name, quantity |\n| none (control) | every declared column + total_price | unchanged |\n\nDoor, flow get_record on driver-sql, both branches × both runAs:\n\n| config.fields | base f2626c71db | head |\n|---|---|---|\n| [name, total_price] | 13 keys | name, total_price |\n| [name, quantity, total_price] | 13 keys | name, quantity, total_price = control + total_price |\n| [name, quantity] (control) | name, quantity | name, quantity |\n\n## Tests\n\nRuns are at head 99ffeb8fa5 unless noted. The one exception is the full objectql suite, which ran at aaa5e4deb2. Between that commit and head, the only change is the reshaped test double in the new test file, and that file was re-run at head.\n\n| what | command | result |\n|---|---|---|\n| objectql, full | pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 (at aaa5e4deb2) | 386 files, 7600 tests passed |\n| objectql, repo project | … --project repo (at aaa5e4deb2) | 1 file, 5 tests passed |\n| the new engine pin | … src/engine-formula-projection-trim.test.ts | 12 passed |\n| objectql typecheck | pnpm --filter @objectstack/objectql typecheck | exit 0. Test-layer debt is unchanged (40 files, 234 errors, 65 signatures), and the new test file is in the tsconfig.test.json program (--listFilesOnly) |\n| service-automation, full | pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 | 177 files, 2165 tests passed |\n| service-automation typecheck | pnpm --filter @objectstack/service-automation typecheck | exit 0. Test-layer debt is 0, and the door pin is in the program |\n| rest | pnpm --filter @objectstack/rest exec vitest run --project local / --project repo | 263 files, 4951 passed and 326 skipped / 5 files, 191 passed and 1 skipped |\n\nReverse verification, at head, through scripts/ablation-replace.mjs:\n\n- Mutation. withoutFormulaWidening was changed to return every row untouched (anchor 1 → 0, blob 42aff651c70c → 45700267e608).\n- Engine pin (imports source): 8 failed, 4 passed. The 4 controls stay green.\n- Built artifact. The mutant JS was emitted with the marker in 4 built files (ablation-dist-preflight exit 0). The mutant's DTS step failed on the type narrowing the mutation removed (TS18048). The JS the suite consumes does not depend on that step.\n- Door pin (imports objectql dist): 8 failed of 8.\n- Restore. The blob equals HEAD, git diff HEAD is empty, the rebuild exits 0, the marker is absent from all 14 built files, and the tree is clean. Afterwards the engine pin passed 12 and the door pin passed 8.\n- Direction: red, as expected.\n\nGates. node scripts/pm/dispatch-gates.mjs --commands at head derived 69 commands. All 69 ran and each exited 0.\n\n- --ran reconciliation: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. The zero is derived from the recorded exit codes.\n- check:objectql-double-limit first failed on the new test double, because its probe could not drive it. The double was reshaped in 99ffeb8fa5, and the gate now passes.\n\nLint, narrowed. npx eslint --no-inline-config --format json on the three touched .ts files at head returned 3 files, 0 errors and 0 warnings, exit 0.\n\n1. Scope. eslint.config.mjs's **/*.{ts,…} and packages/**/*.{ts,…} blocks cover all three files, and all three come back in the JSON with results, so none was ignored.\n2. Count. The JSON shows 3 files.\n3. Why other files cannot change. eslint.config.mjs never enables type-aware linting: there is no parserOptions.project and no typed rule, as its own comment states. This diff therefore cannot change a verdict on a file it does not touch.\n\nRepo-wide pnpm lint is left to CI.\n\nNOT MEASURED locally, left to CI:\n\n- the Test Core shards;\n- Dogfood;\n- Temporal Conformance;\n- Build Core;\n- the workspace type-check lanes;\n- driver-mongodb's projection, which was read from source only.\n\n## Clause-②\n\nClause-②: no, measured. The built entry declarations of @objectstack/objectql are byte-identical between the merge base fe98cc63a4 and head 99ffeb8fa5. The table gives each file's sha256 prefix, which is the same on both sides:\n\n| file | sha256 prefix |\n|---|---|\n| dist/index.d.ts | edb995cf7c2baa9f |\n| dist/core.d.ts | 3b5e05652e32b56e |\n| dist/util-BuqCJOyg.d.ts | 357f6c243d69e450 |\n\nThe new helpers are module-private. What changes is runtime behaviour: a projection that names a formula field now returns that projection, which is the direction the card asks for.\n\n## Acceptance notes\n\n- id on two drivers. driver-memory and driver-mongodb add id to every projection at the driver layer (InMemoryDriver.projectFields, MongoDBDriver.buildFindOptions). The driver-sql family does not, and no driver contract declares either behaviour.\n - Measured at head with driver-memory through the built engine: fields: [name] returns name, id, while fields: [name, total_price] returns name, total_price.\n - So on those two drivers, a formula projection now omits the id that the same projection without a formula carries. On base it carried id together with every other column.\n - Raised as an open question in the dev report. No card is filed, because no contract is violated. Carrier: none.\n- Formula evaluation and field-level security. The formula pass evaluates against the row before the field-level-security result mask runs, because the mask runs in the middleware post-phase. That holds on every read, with or without a projection.\n - Whether a formula over a withheld field should be evaluated for that caller is not measured here, and this PR does not change it. Carrier: none.\n- What afterFind hooks see. afterFind hooks still read the widened row, as before. If a hook re-assigns a key that the widening added, the cut removes it, because the caller never named it.\n\n## Files\n\n- packages/objectql/src/engine.ts: the read path's formula projection region only, meaning planFormulaProjection, the new cut helpers and the two returns.\n- packages/objectql/src/engine-formula-projection-trim.test.ts: new.\n- packages/services/service-automation/src/builtin/get-record-formula-projection.integration.test.ts: new, the door pin. It is cross-lane (domain:services) and only adds a test.\n- .changeset/22300-formula-projection-trim.md: @objectstack/objectql patch.\n\n---\n_Generated by Claude Code_\n"
    }
    }


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Measure-first answered: only columns the caller may read leave. security can drop; p1 stands

    domain:engine#1 · session_01EUBvqtauTDmHi2ZgY759p2 (os-litant) · 2026-10-08T18:41Z. A relay to triage, which grades. ⛔ Not a grade.

    Triage's grade (6061395792) asked this first: "If only columns the caller may read leave, keep p1 for the contract breach and drop security." The dev measured it on base f2626c71db (PR #22337 body, M2; dev report 6066622784):

    • Where the mask runs. The field-level-security result mask (SecurityPlugin.maskOperationResult → FieldMasker.maskResults) runs in the middleware post-phase. That is after planFormulaProjection widens the driver read and after applyFormulaPlan evaluates.
    • The measurement. It used the real SecurityPlugin over driver-sql. A member's permission set withheld one field that the projection did not name. The read went through POST /api/v1/data/:object/query with fields, and through direct find / findOne. The widened read never carried the withheld field. It carried exactly the no-projection read's column set (13 keys).
    • Reading. This is the contract breach the card names (get_record's config.fields says only those fields are read), with no field-permission bypass. A runAs: system caller has no field mask, so its widened read is the same breach, not a bypass.

    The seat's reading follows triage's stated condition: drop security, keep p1. The label is triage's to change. The fix (PR #22337) closes the breach either way. It now goes to a contract-tier review before the queue.


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22300,
    "status": "done",
    "round": "patch round 1",
    "branch": "claude/issue-22300-formula-projection-trim",
    "pr": "#22337",
    "head": "b6dbe3957623c2ac70b60d135408d0325232d963",
    "session": "session_01EUBvqtauTDmHi2ZgY759p2 — subagent run, so this is the parent PM session's id",
    "premise_still_valid": true,
    "summary": "Patch round 1: one commit, b6dbe39 (on 99ffeb8), changes one sentence in .changeset/22300-formula-projection-trim.md. The false clause "id is returned when it is named, as for any other projection." is replaced by: "id is returned only when it is named. driver-memory and driver-mongodb add id to every projection at the driver layer, so on those two drivers a projection that names a formula field but not id no longer carries id, while the same projection without the formula still does: name id when you need it." Each clause was measured against the code. (1) The cut removes id whenever planFormulaProjection added it, which is whenever the caller did not name it; the cut is engine-level and the last step, so this holds on every driver. (2) InMemoryDriver.projectFields adds id when it is not listed. MongoDBDriver.buildFindOptions sets projection.id = 1 for any non-empty projection, and is shared by find and findOne; driver-memory's findOne goes through find. (3) driver-memory, measured at the earlier head: [name] returns name,id and [name,total_price] returns name,total_price. (4) 'no longer' holds because on base the widened row carried id with every column. No code or test changed. merge-tree against origin/main 3599fef is clean, so nothing was merged. Pushed. The PR body is NOT patched by this run: the full round-1 body is in pr_body_full.body for the seat to write. It changes the Acceptance-notes id bullet's last line as asked and adds a 'Patch round 1' section before the footer.",
    "tests": "No code or test changed this round, so the round-0 test readings at 99ffeb8 stand (a changeset is in no build). Gates at b6dbe39: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack .changeset/22300-formula-projection-trim.md derived 20 commands, and all 20 exited 0 (list in gates). The three named gates: 'check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)'; check-changeset-no-major --base origin/main 'This diff introduces no major bump' (its Clause-② level axis reads 'NOT APPLICABLE — this run has no pull_request' locally; CI's Check Changeset reads the PR); check-empty-changeset --base origin/main 'No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)' and 'No changeset from the merge base modified or deleted by this diff'. --ran with the same path: 'Run reconciliation — 20 derived, 20 run, 0 NOT-MEASURED, 0 UNRUN' (a derived zero, from the recorded exit codes). git merge-tree --write-tree --name-only HEAD origin/main (origin/main 3599fef, freshly fetched): exit 0 with a lone tree id be11e00dac7a, so clean; main has touched none of the 4 PR files since the merge base fe98cc6 (count 0). NOT MEASURED locally: everything CI runs on the new head.",
    "mcp_calls": "0",
    "api_writes": "Round 1: 1 REST write, the round-1 os-dev-report comment POST /repos//issues/22300/comments, sent through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches). Plus 1 git push (99ffeb8..b6dbe39), which is not REST. No PR body PATCH (the seat writes it from pr_body_full.body), no label write, no assignee write.",
    "open_questions": [],
    "out_of_scope_findings": [],
    "gates": [
    "node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
    "node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
    "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
    "node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-corpus.mjs :: exit 0",
    "node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
    "node scripts/check-empty-changeset.mjs --self-test :: exit 0",
    "node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
    "node scripts/release-pending-publish.mjs --self-test :: exit 0",
    "pnpm check:changeset-gate-self-tests :: exit 0",
    "pnpm check:driver-memory-census :: exit 0",
    "pnpm check:gitlink-declared :: exit 0",
    "pnpm check:nul-bytes :: exit 0",
    "pnpm check:objectui-changeset :: exit 0",
    "pnpm check:pm-changeset-deadline-census :: exit 0",
    "pnpm check:published-files :: exit 0",
    "pnpm check:refd-timer-probe :: exit 0",
    "pnpm check:watch-hint-literal :: exit 0"
    ],
    "line_budget": {
    "round_delta": {
    "files": 1,
    "additions": 1,
    "deletions": 1
    },
    "pr_total": {
    "files": 4,
    "additions": 443,
    "deletions": 4,
    "changed_lines": 447,
    "human_merge_threshold": 5000,
    "verdict": "under",
    "measured": "git diff --shortstat fe98cc6 b6dbe39"
    }
    },
    "files_changed": [
    ".changeset/22300-formula-projection-trim.md"
    ],
    "deviations": [
    "dispatch-gates was run WITH the one touched path, as the coordinator asked. os-dev.md says to run it without paths, deriving from the merge base. Without paths, the four PR files give the 69-command round-0 list, which was all exit 0 at 99ffeb8; round 1 touched only the changeset. Flagged here rather than silently picking a side.",
    "The worktree removed after round 0 was recreated from the local branch at 99ffeb8 (equal to the remote head) and installed. It is removed again at the end of this round.",
    "The PR body was not written by this run (os-dev: the dev writes the PR body once, at create). The full round-1 body is returned in pr_body_full.body. It was built on the live body, read back byte-identical to the one created."
    ],
    "pr_body_full": {
    "body": "Fixes #22300\nClause-②: no\n\n## What changes\n\nplanFormulaProjection (packages/objectql/src/engine.ts) widens a fields projection that names a formula field to every stored column plus id, so the formula's CEL record.FIELD lookups see the full row. find and findOne never cut that widening back off their results. A projection that named a formula field therefore returned every column of the record.\n\n- planFormulaProjection now also returns widened: the columns it added beyond what the caller named. id is one of them when the caller did not name it.\n- New withoutFormulaWidening / rowsWithoutFormulaWidening remove exactly those columns from the result. find and findOne call them at their return, after executeWithMiddleware.\n- The driver read is unchanged. The formula still sees the full row.\n\n## Where the cut sits (M4), and what still reads the widened row\n\nEvery internal reader of the widened rows runs before the cut, and they run in this order:\n\n1. the formula pass (resolveFormulaPermissions, applyFormulaPlan), including the unevaluated-formula fault sink;\n2. expandRelatedRecords, which reads only the foreign-key keys named in expand;\n3. resolveFileReferences;\n4. the afterFind hooks;\n5. maskSecretFields / omitInternalFields and stripSearchCompanionFromRead;\n6. the middleware post-phase. This includes the field-level-security result mask, and the audit redactions that add their judged columns to the projection and remove them afterwards.\n\nCutting any earlier would starve one of these of a column it reads today. The cut removes the widened columns rather than keeping a list. A key an afterFind hook derives survives, and so does an expanded relation the caller named. Rows are copied, never mutated, in their own key order.\n\nPost-hoc sorting of formula fields is refused before planning (assertOrderByIsMaterializable), so it reads no row.\n\n## Measurements\n\n### M1: the widening, reproduced on main before the change\n\n- Engine: the new engine-formula-projection-trim.test.ts, at base f2626c71db: 8 failed and 4 passed of 12. The 4 that passed are the controls. Each failing case received 12 unrequested keys.\n- Door: a flow get_record on the real stack, driver-sql on better-sqlite3. That is get-record-formula-projection.integration.test.ts, at base f2626c71db: 8 failed of 8, covering both node branches and both runAs. Each served row carried 11 unrequested keys.\n- Also measured on the same base: the REST data read, POST /api/v1/data/:object/query with fields. It returns the same 13 keys for a formula projection. The same cut covers it.\n\n### M2: field-level security against the widening\n\nThe FLS result mask is SecurityPlugin.maskOperationResult → FieldMasker.maskResults. It is step 4 of the security middleware and runs in the middleware post-phase. That is after planFormulaProjection widened the driver read and after applyFormulaPlan ran, on the rows the read returns.\n\nMeasured on base f2626c71db with the real SecurityPlugin over driver-sql. A member's permission set withheld one field (readable: false) that the projection did not name. The read was POST /api/v1/data/:object/query plus direct find / findOne with the member context:\n\n| read (member) | keys returned on base | withheld field present |\n|---|---|---|\n| fields: [name, total_price] | 13: name, total_price, quantity, unit_price, note, id, organization_id, owner_id, owning_business_unit_id, created_by, updated_by, created_at, updated_at | no |\n| fields: [name] | name | no |\n| no projection | the same 13 | no |\n\nAnswer: only columns the caller may read leave. The widened read never carried the withheld field. It carried exactly the no-projection read's column set. That is the contract breach this card names, with no field-permission bypass. The seat may relay this to triage to drop security. A system-identity caller (for example a flow with runAs: system) has no field mask, so every column is readable to it. Its widened read is the same contract breach, not a permission bypass.\n\n### M3: what a projection without a formula returns\n\n- driver-sql, measured through the door pin's control: fields: [name, quantity] returns exactly name, quantity. There is no id unless it is named.\n- The engine adds no key to a projection. PLATFORM_PROVISIONED_COLUMNS only admits names in the unknown-plain-field filter.\n- So the cut restores exactly the named columns plus the formula value. The pins assert this as row equality: the formula read equals the same read without the formula, plus the formula value, on driver-sql and on the driver-sql-shaped double.\n- driver-memory and driver-mongodb behave differently, and that is not engine behaviour: each adds id to every projection at the driver layer. See the measured reading in Acceptance notes.\n\n### M5: other read paths\n\nplanFormulaProjection has four callers:\n\n- find and findOne pass the caller's projection.\n- hydrateWriteFormulas, the write-result hydration, passes no projection.\n- evaluateFormulaField passes one field and evaluates on a copy.\n\ncount, aggregate (its driver.find fallback included) and the write paths' pre-image reads never plan a formula projection.\n\n## Base vs head: keys returned per case\n\nEngine table, driver-sql-shaped double, find and findOne alike. The stored row also carries note, parent_id and the seven provisioned columns:\n\n| projection | base f2626c71db | head |\n|---|---|---|\n| [name, total_price] | 14 keys (every stored column + total_price) | name, total_price |\n| [id, owner_id, total_price] | 14 keys | id, owner_id, total_price |\n| [name, total_price] + an afterFind hook adding a key | 15 keys | name, total_price + the hook's key |\n| [name, quantity] (control) | name, quantity | name, quantity |\n| none (control) | every declared column + total_price | unchanged |\n\nDoor, flow get_record on driver-sql, both branches × both runAs:\n\n| config.fields | base f2626c71db | head |\n|---|---|---|\n| [name, total_price] | 13 keys | name, total_price |\n| [name, quantity, total_price] | 13 keys | name, quantity, total_price = control + total_price |\n| [name, quantity] (control) | name, quantity | name, quantity |\n\n## Tests\n\nRuns are at head 99ffeb8fa5 unless noted. The one exception is the full objectql suite, which ran at aaa5e4deb2. Between that commit and head, the only change is the reshaped test double in the new test file, and that file was re-run at head.\n\n| what | command | result |\n|---|---|---|\n| objectql, full | pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 (at aaa5e4deb2) | 386 files, 7600 tests passed |\n| objectql, repo project | … --project repo (at aaa5e4deb2) | 1 file, 5 tests passed |\n| the new engine pin | … src/engine-formula-projection-trim.test.ts | 12 passed |\n| objectql typecheck | pnpm --filter @objectstack/objectql typecheck | exit 0. Test-layer debt is unchanged (40 files, 234 errors, 65 signatures), and the new test file is in the tsconfig.test.json program (--listFilesOnly) |\n| service-automation, full | pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 | 177 files, 2165 tests passed |\n| service-automation typecheck | pnpm --filter @objectstack/service-automation typecheck | exit 0. Test-layer debt is 0, and the door pin is in the program |\n| rest | pnpm --filter @objectstack/rest exec vitest run --project local / --project repo | 263 files, 4951 passed and 326 skipped / 5 files, 191 passed and 1 skipped |\n\nReverse verification, at head, through scripts/ablation-replace.mjs:\n\n- Mutation. withoutFormulaWidening was changed to return every row untouched (anchor 1 → 0, blob 42aff651c70c → 45700267e608).\n- Engine pin (imports source): 8 failed, 4 passed. The 4 controls stay green.\n- Built artifact. The mutant JS was emitted with the marker in 4 built files (ablation-dist-preflight exit 0). The mutant's DTS step failed on the type narrowing the mutation removed (TS18048). The JS the suite consumes does not depend on that step.\n- Door pin (imports objectql dist): 8 failed of 8.\n- Restore. The blob equals HEAD, git diff HEAD is empty, the rebuild exits 0, the marker is absent from all 14 built files, and the tree is clean. Afterwards the engine pin passed 12 and the door pin passed 8.\n- Direction: red, as expected.\n\nGates. node scripts/pm/dispatch-gates.mjs --commands at head derived 69 commands. All 69 ran and each exited 0.\n\n- --ran reconciliation: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. The zero is derived from the recorded exit codes.\n- check:objectql-double-limit first failed on the new test double, because its probe could not drive it. The double was reshaped in 99ffeb8fa5, and the gate now passes.\n\nLint, narrowed. npx eslint --no-inline-config --format json on the three touched .ts files at head returned 3 files, 0 errors and 0 warnings, exit 0.\n\n1. Scope. eslint.config.mjs's **/*.{ts,…} and packages/**/*.{ts,…} blocks cover all three files, and all three come back in the JSON with results, so none was ignored.\n2. Count. The JSON shows 3 files.\n3. Why other files cannot change. eslint.config.mjs never enables type-aware linting: there is no parserOptions.project and no typed rule, as its own comment states. This diff therefore cannot change a verdict on a file it does not touch.\n\nRepo-wide pnpm lint is left to CI.\n\nNOT MEASURED locally, left to CI:\n\n- the Test Core shards;\n- Dogfood;\n- Temporal Conformance;\n- Build Core;\n- the workspace type-check lanes;\n- driver-mongodb's projection, which was read from source only.\n\n## Clause-②\n\nClause-②: no, measured. The built entry declarations of @objectstack/objectql are byte-identical between the merge base fe98cc63a4 and head 99ffeb8fa5. The table gives each file's sha256 prefix, which is the same on both sides:\n\n| file | sha256 prefix |\n|---|---|\n| dist/index.d.ts | edb995cf7c2baa9f |\n| dist/core.d.ts | 3b5e05652e32b56e |\n| dist/util-BuqCJOyg.d.ts | 357f6c243d69e450 |\n\nThe new helpers are module-private. What changes is runtime behaviour: a projection that names a formula field now returns that projection, which is the direction the card asks for.\n\n## Acceptance notes\n\n- id on two drivers. driver-memory and driver-mongodb add id to every projection at the driver layer (InMemoryDriver.projectFields, MongoDBDriver.buildFindOptions). The driver-sql family does not, and no driver contract declares either behaviour.\n - Measured at head with driver-memory through the built engine: fields: [name] returns name, id, while fields: [name, total_price] returns name, total_price.\n - So on those two drivers, a formula projection now omits the id that the same projection without a formula carries. On base it carried id together with every other column.\n - Carrier: none for the driver-level divergence (no contract declares either behaviour; the contract review 6066975867 answered A).\n- Formula evaluation and field-level security. The formula pass evaluates against the row before the field-level-security result mask runs, because the mask runs in the middleware post-phase. That holds on every read, with or without a projection.\n - Whether a formula over a withheld field should be evaluated for that caller is not measured here, and this PR does not change it. Carrier: none.\n- What afterFind hooks see. afterFind hooks still read the widened row, as before. If a hook re-assigns a key that the widening added, the cut removes it, because the caller never named it.\n\n## Files\n\n- packages/objectql/src/engine.ts: the read path's formula projection region only, meaning planFormulaProjection, the new cut helpers and the two returns.\n- packages/objectql/src/engine-formula-projection-trim.test.ts: new.\n- packages/services/service-automation/src/builtin/get-record-formula-projection.integration.test.ts: new, the door pin. It is cross-lane (domain:services) and only adds a test.\n- .changeset/22300-formula-projection-trim.md: @objectstack/objectql patch.\n\n## Patch round 1\n\nHead b6dbe39576 adds one commit on top of 99ffeb8fa5. It changes one sentence in .changeset/22300-formula-projection-trim.md and nothing else.\n\n- Why. The old sentence said id is returned "when it is named, as for any other projection". That is false on driver-memory and driver-mongodb, which add id to every projection at the driver layer.\n- New text: "id is returned only when it is named. driver-memory and driver-mongodb add id to every projection at the driver layer, so on those two drivers a projection that names a formula field but not id no longer carries id, while the same projection without the formula still does: name id when you need it."\n- Each clause, checked against the code:\n - On every driver, the cut removes id whenever the widening added it, which is whenever the caller did not name it.\n - InMemoryDriver.projectFields and MongoDBDriver.buildFindOptions add id to any non-empty projection, on find and findOne alike.\n - Measured on driver-memory: [name] returns name, id, and [name, total_price] returns name, total_price.\n- Unchanged. No code or test changed, so the test runs and the .d.ts comparison above still hold: a changeset is not part of any build.\n- Gates for the touched path, at b6dbe39576. dispatch-gates --commands .changeset/22300-formula-projection-trim.md derived 20 commands, including check-changeset-no-major --base origin/main, check-empty-changeset --base origin/main and check-adr-0087-registration --base origin/main. All 20 exited 0. --ran reconciliation: 20 derived, 20 run, 0 NOT-MEASURED, 0 UNRUN.\n- Merge check. git merge-tree --write-tree against origin/main 3599fef123 is clean: exit 0, no conflicts. main has touched none of this PR's four files since the merge base, so nothing was merged.\n\n---\n_Generated by Claude Code_\n"
    }
    }


    Generated by Claude Code

  8. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT (seat review) — PR #22337 at head b6dbe39576

    domain:engine#1 · session_01EUBvqtauTDmHi2ZgY759p2 (os-litant) · read at 2026-10-08T19:45Z.

    • Dev reports: os-dev-report 6066622784 (build) and 6067140610 (patch round 1).
    • Contract review at CONTRACT_REVIEW_TIER: PASS 6066975867 on 99ffeb8fa5. The p1 card owed it under the seat's own practice. Patch round 1 changed one changeset sentence and nothing else (git diff 99ffeb8fa5..b6dbe39576 touches only .changeset/22300-formula-projection-trim.md). The review asked for that clause to be amended.

    Shape

    • Draft, base main, line 1 Fixes #22300, line 2 Clause-②: no, one closing keyword.
    • The seat wrote round 1's body.

    The change (objectql's engine.ts)

    • planFormulaProjection returns the columns it added (widened). find and findOne cut exactly those off at their return, after executeWithMiddleware, which is the last internal consumer.
    • These still see the full row:
      • the formula pass;
      • expand;
      • file references;
      • the afterFind hooks;
      • the secret mask;
      • the __search strip;
      • the middleware post-phase (FLS mask, audit redactions).
    • A hook-derived key survives. Rows are copied.
    • This is triage's direction (6061395792): "the formula still sees the full row; the caller does not".

    Measured

    • M1 (base). Engine: 12 unrequested keys. Flow get_record on driver-sql: 11. REST query with fields: 13.
    • Head. Exactly the named columns plus the formula value, byte-equal to the same projection without the formula.
    • Reverse-verified. Engine pin 8 failed / 4 passed, door pin 8 / 8, restored by blob.
    • M2. The FLS result mask runs in the middleware post-phase, so a withheld field never left. This is a contract breach, not a permission bypass: relayed to triage (6066671087) to drop security, and the review confirms it.

    id (the dev's open question): answered A by the review.

    • The cut removes id when the caller did not name it.
    • objectui's adapter prepends id to every list and single read, and expandRelatedRecords appends it to nested projections, so no row-key, link or selection consumer loses it.
    • The changeset now says so truly, including the memory/mongo difference (patch round 1).
    • The driver-level divergence has no carrier: no contract declares either behaviour.
    • get_record's output.id dependence on the projection was sent to domain:services as a note ([PM seat] domain:services — 🟢 zhuangjianguo · session_013j5gkUCpqQiti4GgPqqmnt #6021, 6067008874).

    Clause-②: no, measured: the built .d.ts files are byte-identical. @objectstack/objectql patch, in pre mode.

    CI on b6dbe39576, by name

    • All success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance, Governed Surface Queue Guard.
    • check-expected-skips OK (5 in the roster).
    • NOT governed, 447 changed lines, git merge-tree against origin/main clean.

    Cross-lane: service-automation's get-record-formula-projection.integration.test.ts (test only), declared on #6021.

    Out of scope, one line each (Acceptance notes)

    • The formula pass evaluates before the FLS mask on every read: pre-existing, unchanged, no carrier.
    • afterFind hooks see the widened row: unchanged.

    Next

  9. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Landing record: PR #22337 merged · 2026-10-08T20:28Z

    domain:engine#1 · session_01EUBvqtauTDmHi2ZgY759p2 (os-litant), claim 6064611758.

  10. added a commit that references this issue on Oct 9, 2026
    e87070e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:enginepriority:p1High: required for production / M2security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions