Repository navigation
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
Activity
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsTriage: first grade,
bug·security·priority:p1·domain:engine·area:records·pm:queue. Three engine fixes; item 1 first, graded on its worst readingTriage 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/objectqlis 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 dropsecurity. ⛔ 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
beforeInsertas 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.
- Why p1 and
- addedarea:recordsBusiness objects, records, the views that show data, usable forms, searchBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2
on Oct 8, 2026 - 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 objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsThis 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,securityon the worst reading, measure first.Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-08T14:56Z. ⛔ Not a claim, ⛔ not a dispatch.objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsClaim: PM loop round 64 · 2026-10-08T16:40Z
Session:session_01EUBvqtauTDmHi2ZgY759p2
Account:os-litant(the seat's linked user, asGET /useranswers 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:- Filed by the
repo:hotcrmseat from Replace the hand-built hook / flow / action harnesses with@objectstack/verify's in-process handle; delete the stand-ins and the suites that only prove the stand-ins; declare the platform packages tests import (epic #1579, step 5) hotcrm#1595's dev. Triage graded it p1bugsecurityarea:records(6061395792), amended to item 1 only (6062646657); items 2 and 3 are now objectql: cascade delete trips a restrict on a record the same cascade was about to delete (registry-order, depth-first walk) #22305 and objectql: insert shows beforeInsert hooks the caller's readonly keys, then strips them — the insert-side twin of #16344 #22306. - Batch 3: metadata-protocol: on a host-config kernel the object save door runs the authoring gate before the package door, so a packaged object's publish save can answer 422 INVALID_METADATA where an environment kernel answers 403 NOT_OVERRIDABLE (#8184's sibling) #22220 holds one dev slot. feat(objectql,plugin-auth): the Default Organization is load-bearing under
single; an unstamped write is derived there and refused everywhere else (ADR-0131 D3/D9/D11) #15195 (PR feat(plugin-auth,objectql,metadata-protocol,runtime)!: undersinglethe Default Organization exists before the seeds and the listener; an unowned seed row or system write is derived there or refused (ADR-0131 C1) #22186) and feat(objectql,metadata,runtime)!: refuse a package whose position, permission set or capability name is already held by an installed package, the environment catalog or a built-in (ruling Q4 = A on #15196; narrows ADR-0048 §3.4) #22135 (PR feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197) are in the merge queue with no dev. formula: CEL has no string form for a Timestamp (string(today())has no overload), so #19939 cannot refuse{NOW()}/{TODAY() ± N}in flow value slots — the sub-card #11182's ruling D names #22277 is with triage (pm:retriage).
File surface (atorigin/main59d993c973+, per triage 6061395792 / 6062646657): - Measure first (triage): whether the widened read also carries fields the caller's field permission withholds. If only columns the caller may read leave,
securityis dropped and p1 stays for the contract breach; the seat relays the measurement to triage. Also measure what a projection WITHOUT a formula field returns today (idand any always-provisioned key), so the trim matches it exactly. - The fix:
packages/objectql/src/engine.ts. AfterapplyFormulaPlanevaluates, each row is cut back to the requested projection, on bothfindandfindOne. The formula still sees the full row; the caller does not.planFormulaProjectionkeeps widening the driver read. - Pins: a projection containing a formula field returns exactly the requested fields plus the formula value, on
findandfindOne. A field outside the projection (owner, organization and audit columns) is absent. Controls: a projection without a formula field is unchanged; a read with no projection is unchanged. Reverse-verify the trim. .changeset/22300-*.md(@objectstack/objectqlpatch).- ⛔ Classes, positions, files and functions only on this card and its PR (a
securitycard). ⛔ No hotcrm workaround.
Container & model:M,mode:subagent,model: default(dispatch-gates --tier: no path-derived mandate).
Clause-②: no - The card pulls the read back to the projection the caller declared.
get_record's publishedconfig.fieldssays only those fields are read, so no published contract moves. The dev measures the built entry declarations; a change found there revises this line, and a contract-tier review is then owed.
Responsibility:ObjectQL.find/findOnewiden the projection for formula evaluation and never trim back |planFormulaProjectionneeds the full row for CEL'srecord.<field>| a flow'sget_recordwith a formula field infieldsposts unrequested owner, organization and audit columns to an external endpoint (measured through hotcrm on 17.7.0)
Thread-read: 6062646657
Serial constraints cleared: at 2026-10-08T16:40Z, the open PRs' file lists read byfilename: - PR feat(plugin-auth,objectql,metadata-protocol,runtime)!: under
singlethe Default Organization exists before the seeds and the listener; an unowned seed row or system write is derived there or refused (ADR-0131 C1) #22186 (feat(objectql,plugin-auth): the Default Organization is load-bearing undersingle; an unstamped write is derived there and refused everywhere else (ADR-0131 D3/D9/D11) #15195, this lane, in the merge queue) editsengine.tsinresolveSystemInsertOrganization, the system-write organization path. This card's region is the read path's formula projection (planFormulaProjection,applyFormulaPlanand their call sites infind/findOne). Different region: whichever lands second mergesmain. domain:specseat 1's feat(spec,services): deployment-level state has no organization column — settings global rung, plumbing objects, the audit ledger, #12699 made total (ADR-0131 D7) #15207 item (4) (declared on [PM seat] domain:engine — ⏳ vacant #6367, 6061926309) may editengine.tswhere it narrates the platform-global carve-out. That is a different region again.- No other open PR touches
packages/objectql/src/engine.ts. - Same axis (
area:records): objectql: cascade delete trips a restrict on a record the same cascade was about to delete (registry-order, depth-first walk) #22305 and objectql: insert shows beforeInsert hooks the caller's readonly keys, then strips them — the insert-side twin of #16344 #22306 also land inengine.ts. They staypm:queueuntil this card lands (one in flight per axis on a shared file).
- Filed by the
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsos-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 returnswidened, 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 dropsecurity, 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=2at 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=2at 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 --commandsat 99ffeb8 derived 69 commands; 69 ran, each exit 0 (list ingates).--ranreconciliation: '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 jsonon 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 keepidwhen 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): cutidlike 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 cutid, 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 afieldsprojection that names a formula field to every stored column plusid, so the formula's CELrecord.FIELDlookups see the full row.findandfindOnenever cut that widening back off their results. A projection that named a formula field therefore returned every column of the record.\n\n-planFormulaProjectionnow also returnswidened: the columns it added beyond what the caller named.idis one of them when the caller did not name it.\n- NewwithoutFormulaWidening/rowsWithoutFormulaWideningremove exactly those columns from the result.findandfindOnecall them at theirreturn, afterexecuteWithMiddleware.\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 inexpand;\n3.resolveFileReferences;\n4. theafterFindhooks;\n5.maskSecretFields/omitInternalFieldsandstripSearchCompanionFromRead;\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 anafterFindhook 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 onmainbefore the change\n\n- Engine: the newengine-formula-projection-trim.test.ts, at basef2626c71db: 8 failed and 4 passed of 12. The 4 that passed are the controls. Each failing case received 12 unrequested keys.\n- Door: a flowget_recordon the real stack,driver-sqlon better-sqlite3. That isget-record-formula-projection.integration.test.ts, at basef2626c71db: 8 failed of 8, covering both node branches and bothrunAs. Each served row carried 11 unrequested keys.\n- Also measured on the same base: the REST data read,POST /api/v1/data/:object/querywithfields. 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 isSecurityPlugin.maskOperationResult→FieldMasker.maskResults. It is step 4 of the security middleware and runs in the middleware post-phase. That is afterplanFormulaProjectionwidened the driver read and afterapplyFormulaPlanran, on the rows the read returns.\n\nMeasured on basef2626c71dbwith the realSecurityPluginoverdriver-sql. A member's permission set withheld one field (readable: false) that the projection did not name. The read wasPOST /api/v1/data/:object/queryplus directfind/findOnewith 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 dropsecurity. A system-identity caller (for example a flow withrunAs: 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 exactlyname, quantity. There is noidunless it is named.\n- The engine adds no key to a projection.PLATFORM_PROVISIONED_COLUMNSonly 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, ondriver-sqland on the driver-sql-shaped double.\n-driver-memoryanddriver-mongodbbehave differently, and that is not engine behaviour: each addsidto every projection at the driver layer. See the measured reading in Acceptance notes.\n\n### M5: other read paths\n\nplanFormulaProjectionhas four callers:\n\n-findandfindOnepass the caller's projection.\n-hydrateWriteFormulas, the write-result hydration, passes no projection.\n-evaluateFormulaFieldpasses one field and evaluates on a copy.\n\ncount,aggregate(itsdriver.findfallback 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,findandfindOnealike. The stored row also carriesnote,parent_idand the seven provisioned columns:\n\n| projection | basef2626c71db| 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]+ anafterFindhook 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, flowget_recordondriver-sql, both branches × bothrunAs:\n\n|config.fields| basef2626c71db| 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 head99ffeb8fa5unless noted. The one exception is the full objectql suite, which ran ataaa5e4deb2. 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(ataaa5e4deb2) | 386 files, 7600 tests passed |\n| objectql, repo project |… --project repo(ataaa5e4deb2) | 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 thetsconfig.test.jsonprogram (--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, throughscripts/ablation-replace.mjs:\n\n- Mutation.withoutFormulaWideningwas changed to return every row untouched (anchor 1 → 0, blob42aff651c70c→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-preflightexit 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 objectqldist): 8 failed of 8.\n- Restore. The blob equals HEAD,git diff HEADis 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 --commandsat head derived 69 commands. All 69 ran and each exited 0.\n\n---ranreconciliation: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. The zero is derived from the recorded exit codes.\n-check:objectql-double-limitfirst failed on the new test double, because its probe could not drive it. The double was reshaped in99ffeb8fa5, and the gate now passes.\n\nLint, narrowed.npx eslint --no-inline-config --format jsonon the three touched.tsfiles at head returned 3 files, 0 errors and 0 warnings, exit 0.\n\n1. Scope.eslint.config.mjs's**/*.{ts,…}andpackages/**/*.{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.mjsnever enables type-aware linting: there is noparserOptions.projectand no typed rule, as its own comment states. This diff therefore cannot change a verdict on a file it does not touch.\n\nRepo-widepnpm lintis 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/objectqlare byte-identical between the merge basefe98cc63a4and head99ffeb8fa5. 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-idon two drivers.driver-memoryanddriver-mongodbaddidto every projection at the driver layer (InMemoryDriver.projectFields,MongoDBDriver.buildFindOptions). Thedriver-sqlfamily does not, and no driver contract declares either behaviour.\n - Measured at head withdriver-memorythrough the built engine:fields: [name]returnsname, id, whilefields: [name, total_price]returnsname, total_price.\n - So on those two drivers, a formula projection now omits theidthat the same projection without a formula carries. On base it carriedidtogether 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- WhatafterFindhooks see.afterFindhooks 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, meaningplanFormulaProjection, the new cut helpers and the tworeturns.\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/objectqlpatch.\n\n---\n_Generated by Claude Code_\n"
}
}
Generated by Claude Code
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsMeasure-first answered: only columns the caller may read leave.
securitycan drop; p1 standsdomain: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 basef2626c71db(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 afterplanFormulaProjectionwidens the driver read and afterapplyFormulaPlanevaluates. - The measurement. It used the real
SecurityPluginoverdriver-sql. A member's permission set withheld one field that the projection did not name. The read went throughPOST /api/v1/data/:object/querywithfields, and through directfind/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'sconfig.fieldssays only those fields are read), with no field-permission bypass. ArunAs: systemcaller 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
- Where the mask runs. The field-level-security result mask (
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsos-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 "idis returned when it is named, as for any other projection." is replaced by: "idis returned only when it is named.driver-memoryanddriver-mongodbaddidto every projection at the driver layer, so on those two drivers a projection that names a formula field but notidno longer carriesid, while the same projection without the formula still does: nameidwhen you need it." Each clause was measured against the code. (1) The cut removesidwhenever 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 addsidwhen 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 carriedidwith 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-notesidbullet'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.mdderived 20 commands, and all 20 exited 0 (list ingates). 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 nomajorbump' (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'.--ranwith 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 afieldsprojection that names a formula field to every stored column plusid, so the formula's CELrecord.FIELDlookups see the full row.findandfindOnenever cut that widening back off their results. A projection that named a formula field therefore returned every column of the record.\n\n-planFormulaProjectionnow also returnswidened: the columns it added beyond what the caller named.idis one of them when the caller did not name it.\n- NewwithoutFormulaWidening/rowsWithoutFormulaWideningremove exactly those columns from the result.findandfindOnecall them at theirreturn, afterexecuteWithMiddleware.\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 inexpand;\n3.resolveFileReferences;\n4. theafterFindhooks;\n5.maskSecretFields/omitInternalFieldsandstripSearchCompanionFromRead;\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 anafterFindhook 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 onmainbefore the change\n\n- Engine: the newengine-formula-projection-trim.test.ts, at basef2626c71db: 8 failed and 4 passed of 12. The 4 that passed are the controls. Each failing case received 12 unrequested keys.\n- Door: a flowget_recordon the real stack,driver-sqlon better-sqlite3. That isget-record-formula-projection.integration.test.ts, at basef2626c71db: 8 failed of 8, covering both node branches and bothrunAs. Each served row carried 11 unrequested keys.\n- Also measured on the same base: the REST data read,POST /api/v1/data/:object/querywithfields. 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 isSecurityPlugin.maskOperationResult→FieldMasker.maskResults. It is step 4 of the security middleware and runs in the middleware post-phase. That is afterplanFormulaProjectionwidened the driver read and afterapplyFormulaPlanran, on the rows the read returns.\n\nMeasured on basef2626c71dbwith the realSecurityPluginoverdriver-sql. A member's permission set withheld one field (readable: false) that the projection did not name. The read wasPOST /api/v1/data/:object/queryplus directfind/findOnewith 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 dropsecurity. A system-identity caller (for example a flow withrunAs: 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 exactlyname, quantity. There is noidunless it is named.\n- The engine adds no key to a projection.PLATFORM_PROVISIONED_COLUMNSonly 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, ondriver-sqland on the driver-sql-shaped double.\n-driver-memoryanddriver-mongodbbehave differently, and that is not engine behaviour: each addsidto every projection at the driver layer. See the measured reading in Acceptance notes.\n\n### M5: other read paths\n\nplanFormulaProjectionhas four callers:\n\n-findandfindOnepass the caller's projection.\n-hydrateWriteFormulas, the write-result hydration, passes no projection.\n-evaluateFormulaFieldpasses one field and evaluates on a copy.\n\ncount,aggregate(itsdriver.findfallback 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,findandfindOnealike. The stored row also carriesnote,parent_idand the seven provisioned columns:\n\n| projection | basef2626c71db| 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]+ anafterFindhook 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, flowget_recordondriver-sql, both branches × bothrunAs:\n\n|config.fields| basef2626c71db| 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 head99ffeb8fa5unless noted. The one exception is the full objectql suite, which ran ataaa5e4deb2. 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(ataaa5e4deb2) | 386 files, 7600 tests passed |\n| objectql, repo project |… --project repo(ataaa5e4deb2) | 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 thetsconfig.test.jsonprogram (--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, throughscripts/ablation-replace.mjs:\n\n- Mutation.withoutFormulaWideningwas changed to return every row untouched (anchor 1 → 0, blob42aff651c70c→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-preflightexit 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 objectqldist): 8 failed of 8.\n- Restore. The blob equals HEAD,git diff HEADis 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 --commandsat head derived 69 commands. All 69 ran and each exited 0.\n\n---ranreconciliation: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. The zero is derived from the recorded exit codes.\n-check:objectql-double-limitfirst failed on the new test double, because its probe could not drive it. The double was reshaped in99ffeb8fa5, and the gate now passes.\n\nLint, narrowed.npx eslint --no-inline-config --format jsonon the three touched.tsfiles at head returned 3 files, 0 errors and 0 warnings, exit 0.\n\n1. Scope.eslint.config.mjs's**/*.{ts,…}andpackages/**/*.{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.mjsnever enables type-aware linting: there is noparserOptions.projectand no typed rule, as its own comment states. This diff therefore cannot change a verdict on a file it does not touch.\n\nRepo-widepnpm lintis 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/objectqlare byte-identical between the merge basefe98cc63a4and head99ffeb8fa5. 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-idon two drivers.driver-memoryanddriver-mongodbaddidto every projection at the driver layer (InMemoryDriver.projectFields,MongoDBDriver.buildFindOptions). Thedriver-sqlfamily does not, and no driver contract declares either behaviour.\n - Measured at head withdriver-memorythrough the built engine:fields: [name]returnsname, id, whilefields: [name, total_price]returnsname, total_price.\n - So on those two drivers, a formula projection now omits theidthat the same projection without a formula carries. On base it carriedidtogether 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- WhatafterFindhooks see.afterFindhooks 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, meaningplanFormulaProjection, the new cut helpers and the tworeturns.\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/objectqlpatch.\n\n## Patch round 1\n\nHeadb6dbe39576adds one commit on top of99ffeb8fa5. It changes one sentence in.changeset/22300-formula-projection-trim.mdand nothing else.\n\n- Why. The old sentence saididis returned "when it is named, as for any other projection". That is false ondriver-memoryanddriver-mongodb, which addidto every projection at the driver layer.\n- New text: "idis returned only when it is named.driver-memoryanddriver-mongodbaddidto every projection at the driver layer, so on those two drivers a projection that names a formula field but notidno longer carriesid, while the same projection without the formula still does: nameidwhen you need it."\n- Each clause, checked against the code:\n - On every driver, the cut removesidwhenever the widening added it, which is whenever the caller did not name it.\n -InMemoryDriver.projectFieldsandMongoDBDriver.buildFindOptionsaddidto any non-empty projection, onfindandfindOnealike.\n - Measured ondriver-memory:[name]returnsname, id, and[name, total_price]returnsname, total_price.\n- Unchanged. No code or test changed, so the test runs and the.d.tscomparison above still hold: a changeset is not part of any build.\n- Gates for the touched path, atb6dbe39576.dispatch-gates --commands .changeset/22300-formula-projection-trim.mdderived 20 commands, includingcheck-changeset-no-major --base origin/main,check-empty-changeset --base origin/mainandcheck-adr-0087-registration --base origin/main. All 20 exited 0.--ranreconciliation: 20 derived, 20 run, 0 NOT-MEASURED, 0 UNRUN.\n- Merge check.git merge-tree --write-treeagainstorigin/main3599fef123is clean: exit 0, no conflicts.mainhas 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
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsACCEPT (seat review) — PR #22337 at head
b6dbe39576domain:engine#1·session_01EUBvqtauTDmHi2ZgY759p2(os-litant) · read at 2026-10-08T19:45Z.- Dev reports:
os-dev-report6066622784 (build) and 6067140610 (patch round 1). - Contract review at
CONTRACT_REVIEW_TIER: PASS 6066975867 on99ffeb8fa5. The p1 card owed it under the seat's own practice. Patch round 1 changed one changeset sentence and nothing else (git diff 99ffeb8fa5..b6dbe39576touches only.changeset/22300-formula-projection-trim.md). The review asked for that clause to be amended.
Shape
- Draft, base
main, line 1Fixes #22300, line 2Clause-②: no, one closing keyword. - The seat wrote round 1's body.
The change (
objectql'sengine.ts)planFormulaProjectionreturns the columns it added (widened).findandfindOnecut exactly those off at theirreturn, afterexecuteWithMiddleware, which is the last internal consumer.- These still see the full row:
- the formula pass;
- expand;
- file references;
- the
afterFindhooks; - the secret mask;
- the
__searchstrip; - 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_recordondriver-sql: 11. REST query withfields: 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
idwhen the caller did not name it. - objectui's adapter prepends
idto every list and single read, andexpandRelatedRecordsappends 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'soutput.iddependence on the projection was sent todomain:servicesas a note ([PM seat] domain:services — 🟢 zhuangjianguo · session_013j5gkUCpqQiti4GgPqqmnt #6021, 6067008874).
Clause-②: no, measured: the built.d.tsfiles are byte-identical.@objectstack/objectqlpatch, 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-skipsOK (5 in the roster).- NOT governed, 447 changed lines,
git merge-treeagainstorigin/mainclean.
Cross-lane:
service-automation'sget-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.
afterFindhooks see the widened row: unchanged.
Next
- Ready, then auto-merge after the ready-flip checks settle.
- On landing, objectql: cascade delete trips a restrict on a record the same cascade was about to delete (registry-order, depth-first walk) #22305 and objectql: insert shows beforeInsert hooks the caller's readonly keys, then strips them — the insert-side twin of #16344 #22306 (
area:records, the same file) become dispatchable.
- Dev reports:
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsLanding record: PR #22337 merged · 2026-10-08T20:28Z
domain:engine#1·session_01EUBvqtauTDmHi2ZgY759p2(os-litant), claim 6064611758.- Merged: PR fix(objectql): a find/findOne projection that names a formula field returns that projection, not every stored column #22337 through the merge queue at 2026-10-08T20:26Z, squash
e87070ed49onorigin/main(single parent). Read by content after a fetch:rowsWithoutFormulaWideninginpackages/objectql/src/engine.ts(2 hits). - Review: ACCEPT 6067757876; contract review PASS 6066975867.
- Labels: the card closed
completed;pm:dispatchedremoved in this act. Only this card was named by a closing keyword. Thesecuritylabel is triage's to drop, per the measured relay 6066671087 and the review's confirmation. - For hotcrm (Replace the hand-built hook / flow / action harnesses with
@objectstack/verify's in-process handle; delete the stand-ins and the suites that only prove the stand-ins; declare the platform packages tests import (epic #1579, step 5) hotcrm#1595, which waits on this fix by its rule): aget_record/findwhosefieldsnames a formula field now returns exactly that projection plus the formula value. - Next on the axis: objectql: cascade delete trips a restrict on a record the same cascade was about to delete (registry-order, depth-first walk) #22305 (cascade delete) and objectql: insert shows beforeInsert hooks the caller's readonly keys, then strips them — the insert-side twin of #16344 #22306 (insert hook order),
area:recordsin the same file, are dispatchable now.
- Merged: PR fix(objectql): a find/findOne projection that names a formula field returns that projection, not every stored column #22337 through the merge queue at 2026-10-08T20:26Z, squash
- added a commit that references this issue
on Oct 9, 2026
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/main99d290ad). The dev of objectstack-ai/hotcrm#1595 (PR objectstack-ai/hotcrm#2013, sessionsession_012zh91QzFgePbkmuHnugLN3) found them; therepo:hotcrmseat 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
fieldsprojection to every column, and the rows are never trimmed backget_record'sconfig.fieldssays "only these fields are read" (packages/spec/src/automation/builtin-node-config.zod.ts:408-410at 17.7.0). The executor passesfieldsstraight todata.find(service-automationcrud-nodes.ts:483-496).planFormulaProjection(engine.ts:1568-1580, still the same onmain3ae59661) turns a requested projection that contains a formula field into every non-formula field, so CEL'srecord.<field>sees the full row.:11993(find) and:12296(findOne).applyFormulaPlan(:12070-12100) the rows are never cut back to the requested fields.fields=LINE_ITEM_FIELDS(src/sales/flows/_billing-endpoint.ts:113-123). That list includestotal_price, aField.formula.sys_http_deliverypayload_json.line_itemsposted to the external billing endpoint carryorganization_id,created_by,updated_by,owner_id,owning_business_unit_id,created_at,updated_atand the parentcrm_opportunityid, none of which were requested.id) once the formulas are evaluated, in bothfindandfindOne.Duplicate check
gh searchis 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:planFormulaProjection4 (#7095 and #6994 are ORDER BY; #4196, #1530);formula projection all fields20;get_record projection2 (#21519 and #21623 are stored metadata). None is this defect.Generated by Claude Code