Skip to content

fix(objectql): a formula field and a CEL defaultValue answer current_user.can() from the security service (#20082) - #20138

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20082-formula-default-can
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20082-formula-default-can

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20082

Clause-②: no (narrowing)

A formula field and a CEL defaultValue that call current_user.can(object, verb) now get the acting subject's effective object permissions. That is the map PR #20079 wired for option visibility. It comes through ObjectQL.registerEffectiveObjectPermissionsResolver and formula's toEvalPermissions, and it is resolved at most once per engine operation.

The two sites, base against head

Measured one-shot through a real ObjectQL with a SQL driver (better-sqlite3 :memory:) and a real SecurityPlugin. The caller holds crm_account: allowRead + allowEdit, so edit is the granted verb and delete the denied one. Base is 55daf89d74; head is this branch. The same cells are pinned permanently in packages/objectql/src/engine-formula-default-permission.test.ts with a resolver double.

site granted denied no resolver resolver throws
formula field on find, findOne, insert echo, update echo base null, no log; head true base null, no log; head false base null, no log; head null plus one warn per operation, reason: 'no-permission-source' base null, no log; head null plus one warn per operation carrying the error, reason: 'permission-resolution-failed'
CEL defaultValue on insert base unset plus warn; head stores true base unset plus warn; head stores false unchanged: unset plus the existing warn, which carries formula's "carries no permission data" refusal base unset plus warn, row written; head the insert is refused with the resolver's own error, and nothing is written
a required field with that default base refused VALIDATION_FAILED / required; head admitted (true) head admitted (false) unchanged: refused VALIDATION_FAILED / required head refused with the resolver's own error

At base the resolver was asked 0 times by either site, whether it granted, withheld or threw. That is H1, confirmed.

The rule each site follows (H3)

  • Formula field (a read). Today a formula that does not evaluate reads null and logs nothing: applyFormulaPlan assigns r.ok ? … : null. A read cannot refuse a row over one computed field, so a can formula with no map keeps that null. It is no longer silent: the engine logs one warn per operation naming the object, the can fields and the reason. A throw fails closed. It is never read as "no grants", which would make an empty map answer false, and never as a grant.
  • CEL default (a write). Today a default that does not evaluate is left unset with the warn "Failed to evaluate default expression". A required field so defaulted is then refused by required-validation (measured at base with the real plugin: VALIDATION_FAILED, field d_req, code required). With no resolver that rule is kept byte for byte: the absent member passes NO map. A throw fails closed the way the option gate does. A row whose can default needed the map is refused with the resolution's own error, re-raised untouched. Under insertMany only that row is refused, and the validate() preview rejects the same way. A row that supplies the field is never refused by a resolution it did not need.

Where the map comes from, and how often (H2)

  • permissionResolution(context) is one lazy, memoised resolver ask per engine operation. It is undefined, meaning no map, when no resolver is registered or the operation has no acting user. The same conditions apply to the option gate.
  • Read path. find and findOne resolve after the driver returns, and only when a planned formula calls can and at least one row came back. They use opCtx.context, which is the context the security middleware already ran with. So plugin-security's per-context permission-set memo serves the resolution.
  • Write path. One resolution per write is shared by the CEL defaults, the re-default after the static-readonly strip, the option gates and the formula fields on the response. resolveOptionPermissions now receives the write's resolution instead of calling the resolver itself. When it asks, and what a throw does, are unchanged; its 13-case suite passes as it was.
  • The "needs the map" test for both sites is readsPermissionPredicate, the option gate's own AST reading of a receiver can call. It is exported from rule-validator.ts so that there is one detector, not two. It is not re-exported from the package entry.

Resolver asks per operation (pinned):

operation base head
find over 4 rows with a can formula 0 1
findOne, and a by-id update echo 0 1 each
insert with a can default and a can formula echo 0 1
insert that picks a can-gated option AND defaults a can field AND echoes a can formula 1 1
batch insert of 6 rows 0 1
validate() over 2 rows 0 1
two consecutive finds 0 2 (never kept across operations)
object with no can anywhere, even with a throwing resolver 0 0
system read (no acting user) 0 0

Declaration (H4)

  • Narrowing. When the resolution fails, an insert whose can default needed it is now refused. At base that row was written with the field unset. So the changeset and this body carry Clause-②: no (narrowing), a BREAKING banner and adr-0087: not-required (no-migration-prescription). No authored key, stored shape, export or route changes.
  • The claim reads Clause-②: no. The arm is added here because the dispatch's H4 says a write whose default now fails closed is a narrowing to declare. The seat owns the claim line.
  • Reachability of that path. In the shipped composition, SecurityPlugin's middleware resolves the same memoised permission sets before the write and already refuses on a resolution failure. The newly refused path is therefore reachable only when buildEffectiveObjectPermissions throws, when the map is off-shape, or with a third-party resolver.
  • Widening. No key, export or route is added. A required field defaulted by can is now admitted where it was always refused. That is a declared default finally evaluating, not a new surface.

Tests

Head is 4fcfbed346. Each line names the commit it ran at. The only commits after 9e622fbedd edit the new test file: they type its options objects and make its driver refuse unknown WHERE combinators.

  • Base red. The new pin against engine.ts and rule-validator.ts restored to 55daf89d74 (blob a9ec130693 = base blob), then restored to HEAD (blobs 15ca43c2eb and 90cec7aea7 = HEAD, git diff HEAD empty) gave Tests 27 failed | 3 passed (30). For example, "expected null to be true" (granted find), "expected [] to have a length of 1 but got +0" (no-resolver warn), and "expected ValidationError: flag is required to be Error: permission store unreachable" (throw on a required default). The 3 that pass are controls that must hold on both sides: no-resolver on a required default, no can anywhere, and a system read. This ran at ca6dea2249, with 30 cases; the 31st case, the validate() rejection, was added after it.
  • Head (4fcfbed346). The new pin plus engine-option-permission-predicate gave Test Files 2 passed (2) and Tests 44 passed (44), which is 31 plus 13.
  • Ablation (at ca6dea2249; src-resolved, so no build). I ran scripts/ablation-replace.mjs to replace the memo's pending ??= with pending =. The anchor went 1 to 0, the marker 0 to 1, and the blob went 15ca43c2eb to 592f152bbd. The pin then gave Tests 4 failed | 26 passed (30): every "one resolution" cell got 2 or 3 asks. The file was restored with blob == HEAD and git diff HEAD empty.
  • @objectstack/objectql. vitest run --project local gave Test Files 315 passed (315) and Tests 5373 passed (5373). It ran at 9e622fbedd; the only later commit retypes the options objects in the new test file. --project repo gave 1 file, 5 tests passed. typecheck (src, scripts and the test layer) exited 0 at 4fcfbed346, and the test layer still holds 40 files and 234 errors in the debt ledger. The new test file is in tsconfig.test.json's program (--listFiles) with 0 errors.
  • @objectstack/plugin-security (9e622fbedd). The full suite, run with objectql aliased to source, gave Test Files 135 passed (135) and Tests 2676 passed (2676).
  • Dogfood (55ec8feee6). On a closure built by turbo (64 tasks), field-zoo-roundtrip, field-zoo-value-shape, showcase-static-readonly (the re-default path), showcase-fls-read-mask-strip and expression-conformance gave Test Files 5 passed (5) and Tests 109 passed (109).
  • Targeted neighbours (9e622fbedd). engine-option-permission-predicate, rule-validator.option-visibility, engine-write-formula-hydration, engine-formula-scale, engine-cel-default-temporal-shape, engine-default-value-tokens and record-title, run with the new pin, gave Test Files 8 passed (8) and Tests 170 passed (170).
  • eslint, narrowed (4fcfbed346). eslint --no-inline-config --format json on the 3 changed .ts files found 3 files, 0 errors and 0 warnings. The population is read from eslint.config.mjs, and no file was reported ignored. The config sets no parserOptions.project, so no type-aware rule can move a verdict on an untouched file.

Gates

  • Derivation. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 4fcfbed346 derived 65 commands.
  • Run. All 65 were run at 4fcfbed346, and all 65 exited 0.
  • Reconciliation. --ran reported 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.
  • Fixed on the way. Two families were red at 9e622fbedd and are green at head:
    • check:query-options-erasure: the new test's options objects were erased to any, and the test surface grew from 236 to 244 sites. They are now typed, and the surface is back to 236.
    • check:where-matcher: the test driver read an unknown $ key as a field name. It now refuses it.
  • Prerequisite, then measured. check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET) on the first pass. Later gates in the list built the missing dists, and it exits 0 at head. A CJS/ESM load probe of packages/objectql/dist sees ObjectQL and evaluateFormulaField on both.
  • check-changeset-no-major, fed this body as a pull_request payload (--event). It reported LEVEL AXIS: this PR declares clause-② no (narrowing), and no package whose packages/**/src/** it moves is graded patch.
  • check-adr-0087-registration. It reported 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition and [BREAKING+clause-②-narrowing] not-required (no-migration-prescription).
  • check-issue-citations --base 55daf89d74. Exit 0: 17 resolves.
  • Left to CI. The derivation names 5 path-scheduled CI jobs and 4 type-check lanes. They are CI's own runs, and they are NOT MEASURED locally.

Acceptance notes

  • carrier: 承接者:无. The expression-conformance ledger row cel-formula declares failPolicy: 'fail-soft-log', but a formula that faults for any other reason still reads null with no log line (applyFormulaPlan's r.ok ? … : null). This PR logs only the can case, which is its own. This is read off the code, not measured at a public door.
  • carrier: 承接者:无. evaluateFormulaField and resolveRecordTitle are synchronous and hold no resolver, so a title formula calling can still yields null there. The docblock and the changeset say so.
  • carrier: 承接者:无. Each expand of a related object is its own find, so it asks the resolver again. plugin-security's per-context memo absorbs the set resolution; the map itself is rebuilt.
  • carrier: 承接者:无. A system read, which has no acting user, of a can formula reads null with no warn, as any current_user formula does with no subject.

Deviations from the claim's file surface


Generated by Claude Code

…n() from the resolved permission map

applyFormulaPlan (find, findOne, write echo) and applyFieldDefaults now pass
the acting subject's effective object-permission map, resolved at most once
per operation through the registered resolver and shared with the option
gates. No resolver passes no map; a failed resolution reads null with a warn
on a read and refuses the write for a row whose can() default needed it.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…ults across granted, denied, no resolver and a throwing resolver

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…he security service; pin the validate() rejection

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…ather than erasing them

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…binator it does not implement

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 13 documentable anchor(s).

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-flow.mdx (via findOne (symbol, a method of class ObjectQL))
  • content/docs/automation/hook-bodies.mdx (via findOne (symbol, a method of class ObjectQL))
  • content/docs/automation/webhooks.mdx (via findOne (symbol, a method of class ObjectQL))
  • content/docs/kernel/contracts/data-engine.mdx (via findOne (symbol, a method of class ObjectQL))
  • content/docs/kernel/contracts/index.mdx (via findOne (symbol, a method of class ObjectQL))
  • content/docs/kernel/events.mdx (via findOne (symbol, a method of class ObjectQL))
  • content/docs/permissions/attachments-access.mdx (via findOne (symbol, a method of class ObjectQL))
  • content/docs/permissions/field-level-security.mdx (via findOne (symbol, a method of class ObjectQL))
  • content/docs/permissions/record-view-auditing.mdx (via findOne (symbol, a method of class ObjectQL))
  • content/docs/permissions/rls.mdx (via findOne (symbol, a method of class ObjectQL))
  • content/docs/permissions/system-context.mdx (via findOne (symbol, a method of class ObjectQL))
  • content/docs/protocol/objectql/schema.mdx (via findOne (symbol, a method of class ObjectQL))
  • content/docs/ui/react-pages.mdx (via findOne (symbol, a method of class ObjectQL))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via findOne (symbol, a method of class ObjectQL))
  • content/docs/releases/v16.mdx (via findOne (symbol, a method of class ObjectQL))
  • content/docs/releases/v17/17-0.mdx (via findOne (symbol, a method of class ObjectQL))
  • content/docs/releases/v17/index.mdx (via findOne (symbol, a method of class ObjectQL))

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

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 69 pages)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 536f2d52fc5d3aa6067782f884c1798614710f02 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 272d0526171097904b08dac3def40254a7cc9b78 — the merge of head 4fcfbed346c2cdb73653b63ca345d5ea45a0eceb into base 536f2d52fc5d3aa6067782f884c1798614710f02, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 272d0526171097904b08dac3def40254a7cc9b78 && git checkout 272d0526171097904b08dac3def40254a7cc9b78
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 536f2d52fc5d3aa6067782f884c1798614710f02 4fcfbed346c2cdb73653b63ca345d5ea45a0eceb && git checkout -B drift-repro 536f2d52fc5d3aa6067782f884c1798614710f02 && git merge --no-ff 4fcfbed346c2cdb73653b63ca345d5ea45a0eceb

node scripts/docs-audit/affected-docs.mjs --json 536f2d52fc5d3aa6067782f884c1798614710f02

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 536f2d52fc5d3aa6067782f884c1798614710f02 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4fcfbed346c2cdb73653b63ca345d5ea45a0eceb

Scope: 4 files (+834/−42) on merge base 55daf89d74:

  • packages/objectql/src/engine.ts;
  • packages/objectql/src/validation/rule-validator.ts (one module-level export);
  • the new pin engine-formula-default-permission.test.ts;
  • .changeset/20082-formula-default-can.md.

Measured in detached worktrees at head and base, each with its objectql, plugin-security and driver-sql closure built. The stack was a real ObjectQL + SqlDriver (better-sqlite3 :memory:) + the real SecurityPlugin, with its registered resolver wrapped only to count asks, throw, or answer off-shape. The PR's own pin was also run.

① Derived judgments

  • H1, base passes no map: CONFIRMED. At base the two value sites asked the resolver 0 times in every scenario. Only the option gate asked.
  • Sites table, base → head, real stack: every dev cell CONFIRMED.
    • A formula field on find, findOne, the insert echo and the update echo:
      • granted null → true, and denied null → false;
      • no resolver: null plus exactly one warn per operation (no-permission-source, naming the fields);
      • throw or off-shape: null plus one warn (permission-resolution-failed) carrying the error.
    • A CEL defaultValue on insert:
      • granted stores true, and denied stores false;
      • no resolver: IDENTICAL at base and head;
      • throw: base wrote the row with the field unset; head refuses it with the resolver's own error object (AUTHZ_STORE_UNAVAILABLE / 503, not a ValidationError), 0 rows written;
      • off-shape: refused with a TypeError;
      • insertMany refuses only the row that needs the default;
      • validate() rejects the same way.
    • A required field defaulted by can(): refused required at base; at head granted and denied are admitted, no resolver is identical, and throw is refused with the resolver's error.
  • Never an unearned true: HOLDS. Every fault path reads null, leaves the field unset, or refuses. false comes only from a resolved map. A system read and an object with no can() under a throwing resolver both make 0 asks and are byte-identical at base and head.
  • H2, one resolution per operation: CONFIRMED.
    • Asks per operation: find 1, findOne 1, update 1, insertMany 1, validate() 1, two finds 2.
    • An insert with a can default, a can-gated option and a can echo asks once in total. Stack traces attribute that ask to the default.
    • The option gate asks exactly where it asked at base.
    • The answer is a per-operation closure and is never kept past the operation.
  • PR feat(objectql,plugin-security,core): the server answers current_user.can() in an option's visibleWhen #20079's option gate: behaviour unchanged, measured. On crm_opt under granted, throw, off-shape and no resolver, base and head are IDENTICAL: the same admissions, refusals, ask counts and warn.
  • The pins discriminate.
    • The head pin against base engine.ts / rule-validator.ts: 28 failed, 3 passed of 31. The 3 passes are the controls that should agree on both sides.
    • The memo ablation (pending ??= → pending =): 4 of 31 fail, exactly the four one-resolution cells.
    • Restored by blob, with git diff HEAD empty.
  • Public surface: unchanged, measured.
    • readsPermissionPredicate is not named by src/index.ts, src/core.ts, any built dist/*.d.ts or any export list. The exports map is . and ./core only.
    • The d.ts chunk gains only four private ObjectQL members.
    • hydrateWriteFormulas and PermissionResolution are module-private.
  • Narrowing census. The only accepted → refused cell is the declared one: an insert or validate() row whose can default needed a resolution that throws or is off-shape. Everything else is identical or admits more.
  • Reachability of the new refusal in the shipped composition: confirmed. The security middleware resolves the same memoised sets first and fails closed. So the new refusal is reached by a throwing buildEffectiveObjectPermissions, an off-shape map, or a third-party resolver. A concurrent write can retire the memo between the two asks, but that is the same window the option gate has had since feat(objectql,plugin-security,core): the server answers current_user.can() in an option's visibleWhen #20079.
  • Scope: RIGHT. packages/core and packages/formula are untouched, and the diff has no aggregate / having hunk.
  • Merge with current main (fe677aeeed, 11 commits past base, after fix(objectql)!: having takes the rest of where's filter doors — the comparand-type door, row-independent refusals, a resolved { $field }, and a refused non-condition #20117, fix(spec, drivers)!: a $like / $ilike pattern holding U+0000 is refused by every driver that answers $like, instead of being cut at the NUL on SQLite (#20041) #20124, fix(core): the effective object-permission map covers a plain * grant, so current_user.can() agrees with checkObjectPermission for a wall-less org admin (#20083) #20132 and fix(metadata-protocol): a page saved without type is stored and served with PageSchema's declared default (#20101) #20133 landed): git merge-tree is clean. Main's engine.ts hunks (the import block and the having region) are disjoint from this PR's.
  • CI at this head: 34 runs, 31 success and 3 skipped, 0 failed and 0 pending. All 7 required contexts are success, and so is Check Changeset.

② Semver level

@objectstack/objectql minor, Clause-②: no (narrowing), BREAKING, ADR-0087 not-required (no-migration-prescription): RIGHT.

③ Boundary flags

Implemented-by: claude/issue-20082-formula-default-can
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 25, 2026 08:43
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit a08e059 Sep 25, 2026
35 of 36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20082-formula-default-can branch September 25, 2026 09:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants