fix(formula,objectql): name the working guard for an optional lookup in both traversal refusals - #20049
Conversation
…ing repairs end to end Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…ce in both traversal refusals The mixed-shape refusal prescribed `record.<ref>.id`, which is no null guard: it reads through the reference too, so an empty reference is then refused as "no single related record", whose own prescription named no spelling. Both refusals now name the `conditional` wrapper (`when: record.<ref> != null`), in the one spelling referenceGuardRepair already uses, and `required: true`. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…guard prescriptions Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…eld and message Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 21 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 990cc7a9bf6157084e90baab30fcf44ec1e2813e && git checkout 990cc7a9bf6157084e90baab30fcf44ec1e2813e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5581d3000f27daa19991cf9d13d5ad1ed8cf8913 0f4c443ac9ed56c9d18ba5af465cd23c0af7ee29 && git checkout -B drift-repro 5581d3000f27daa19991cf9d13d5ad1ed8cf8913 && git merge --no-ff 0f4c443ac9ed56c9d18ba5af465cd23c0af7ee29
node scripts/docs-audit/affected-docs.mjs --json 5581d3000f27daa19991cf9d13d5ad1ed8cf8913 |
Contract reviewServed-tier: Scope read: card #20007 (body and all 6 comments; the dev's Measured in two detached worktrees, one at head and one at the merge base, both removed afterwards:
① Derived judgments
② Semver level
③ Boundary flagsChangeset: every sentence is TRUE:
PR body: TRUE where measured. The objectql 5266 and lint 4138 totals and the dev's local gate readings are UNMEASURED here; CI is green. Non-blocking:
Implemented-by: VERDICT: PASS Isolated reviewer: a contract-review-tier subagent, fed only the card, the cited record, the PR and AGENTS.md; adopted by the seat. |
…can() in an option's visibleWhen (objectstack-ai#20079) Fixes objectstack-ai#18783 Clause-②: yes (widening) Executes ruling A on the card (maintainer 「同意」, comment 5725678115): the census comes first, then the reader, then the wiring. The server now answers `current_user.can(object, verb)` in an option's `visibleWhen`. Before this PR the predicate faulted on every authenticated write and the value was admitted unenforced. The interface member is the one PR objectstack-ai#19622 declared: `ISecurityService.getEffectiveObjectPermissions?(context?)`. `packages/spec` is not touched. **Declared cross-lane edits.** `packages/plugins/plugin-security` is `domain:services` surface; the ruling places it on this card. `packages/core` and `packages/plugins/plugin-hono-server` are outside the claim's file surface. They are touched because the dispatch's H2 requires the `/auth/me/permissions` route and the member to read ONE function, and `@objectstack/core` is the only package both already depend on (plugin-hono-server must not take a runtime dependency on plugin-security). ## What lands - **objectql** gets a new engine seam, `registerEffectiveObjectPermissionsResolver(fn)`. It is the same shape as `registerWriteGateProbe`, which the security plugin already registers on the engine. - `resolveOptionPermissions` asks the resolver at most ONCE per write: a batch insert, a by-id update, an N-row bulk update or a `validate()` preview. - It asks only when three things hold: a resolver is registered, the write has an acting user, and some payload picks an option whose `visibleWhen` calls `can`. - The answer goes through formula's `toEvalPermissions`. A resolution throw, or a map that is not the published shape, is re-raised untouched for exactly the payloads that needed the map: the write fails CLOSED. - **rule-validator**: `evaluateValidationRules` takes `permissions` and passes it to the per-option `visibleWhen` evaluation. - `optionVisibilityReadsPermissions` answers "does this write need the map". It reads the parsed CEL AST for a receiver call named `can`, and uses the same picker (`pickedGatedOptions`) the evaluator judges with. - `undefined` stays "no permission data": the predicate is loudly unevaluable and the existing fail-open branch admits the value with a warn that names the missing input. - **plugin-security** implements `getEffectiveObjectPermissions` on the class and on the registered `security` literal, and registers the same method on the engine. - The member resolves the sets with `resolvePermissionSetsForContext` and builds the map with `buildEffectiveObjectPermissions` over the plugin's engine. It freezes the map at the top level. - A resolution failure propagates untouched and never becomes `{}`. - An engine without the seam gets one `warn` at start. - **core** gets `buildEffectiveObjectPermissions`: the most-permissive merge, then `seedSuperUserRestrictedObjects`, `foldWildcardSuperUser`, `clampManagedObjectWrites` and `annotateEffectiveApiOperations`. The four folds and `ManagedSchemaLike` / `ApiExposureSchemaLike` moved here unchanged (reindented) from plugin-hono-server. - **plugin-hono-server**: `/auth/me/permissions` builds its `objects` slot with `buildEffectiveObjectPermissions`. The six moved names are re-exported from `@objectstack/core`, so the package root still exports them. ESM probe: the re-exported `foldWildcardSuperUser` is the same function object as core's (`===`). ## Census — every in-repo evaluation site that binds `current_user` The grep, over non-test source outside `packages/formula` (the engine itself) and `packages/qa`: ``` git grep -n -E "\b(ExpressionEngine|celEngine|templateEngine)\.evaluate\b|\bresolveSeed(Record)?\(|\bcompileCelToFilter\(" -- 'packages/**/*.ts' ':!**/*.test.ts' ':!**/dist/**' ':!packages/formula/**' ':!packages/qa/**' ``` It gives 24 lines, 18 of them code (6 are comments). Completeness control: a token scan for `ExpressionEngine|celEngine|templateEngine|resolveSeedRecord|resolveSeed|buildScope|getEngine` over the same tree gives 24 files. Every file outside the grep's population mentions the tokens only in comments, or uses an unrelated `getEngine`. Positive control: the grep's population contains the site the ruling names (`rule-validator.ts` `evaluateOptionVisibility`). | site (file : symbol) | binds `current_user` | author-reachable `can` today | verdict | |---|---|---|---| | `objectql/src/validation/rule-validator.ts` : `evaluateOptionVisibility` (reached from 5 engine call sites: insert, by-id update, bulk update twice, `validate()`) | yes (`buildEvalUser`) | yes. It faulted with "carries no permission data" and the fail-open branch ADMITTED the value (measured on the base head, below). This is a live violation and, by the ruling's own words, the p1 trigger. | **wired here** | | `objectql/src/engine.ts` : `applyFormulaPlan` (formula virtual fields, read and write-back) | yes (own `{id, positions}`) | yes: value `null` on read and on the insert echo, with NO log (measured at this head with a resolver registered) | out of this card's plumbing: a value expression, not a predicate, and it builds its own user object. Reported as a finding. | | `objectql/src/engine.ts` : `applyFieldDefaults` (CEL `defaultValue`) | yes (own `{id, positions}`) | yes: field left unset, and `Failed to evaluate default expression` names the missing input (measured) | out of this card's plumbing, same reason. Reported as a finding. | | `metadata-protocol/src/seed-loader.ts` : `resolveSeedRecord` | yes (seed identity, or `{ id: null }`) | the record is dropped loudly (`errored++`, an actionable error) | out of scope: boot-time replay with no request and no acting subject whose grants would mean anything. The loud refusal is the correct answer. | | `plugin-security/src/rls-compiler.ts` : `compileExpressionOutcome` (`compileCelToFilter`, `current_user` as a lowering variable) | as a filter variable | `unsupported method "can()"`: the policy drops and RLS denies (fail closed) | out of scope: an RLS predicate is lowered into a driver filter, and a filter cannot consult a permission map | | `lint/src/validate-rls-predicate-enforceability.ts` | probe variable | authoring gate, not a runtime evaluation | out of the population | | hook `condition` (`hook-wrappers.ts`), `readonlyWhen`, `requiredWhen` x2, `script`, `conditional` (`rule-validator.ts`), approvals expression approver, share-link eligibility, flow `celScope` x2, sharing-rule seeder, lint sharing gate | **no** | n/a | outside the census: `current_user` is not bound there | ## H1 — red on the base head, green here Ruling pin, `rule-validator.option-visibility.test.ts`, run on the base (`2274894cc`) before the fix: - `REFUSES a can-gated option … withholds the verb` failed with `expected undefined to be an instance of ValidationError` (admitted). - `ADMITS it …` failed with `expected [ { …(2) } ] to have a length of +0 but got 1` (the fail-open warn). - The run ended `Tests 7 failed | 31 passed (38)`. The no-`can` control and the no-permission-data case were green on the base, which is their point. With the fix: `Tests 38 passed (38)`. ## H2 — the producer, and byte-equality The `/auth/me/permissions` merge lived inline in plugin-hono-server's `current-user-endpoints.ts`. It is now `buildEffectiveObjectPermissions`, read by both the route and the member. - **Route unchanged.** Whole response bodies from the base route and this head's route, for the same resolved sets and schemas, are byte-identical on five fixtures (super-user without export, super-user with export, wall-less org admin, plain rep, nothing). Measured one-shot: lengths 1461/381/633/336/168, all `equal=true`. - **Permanent pins, both halves.** `current-user-endpoints-effective-objects.test.ts` pins that the route's `objects` equals `buildEffectiveObjectPermissions` over the resolved sets, byte for byte. `get-effective-object-permissions.test.ts` pins that the member equals the same function over `resolvePermissionSetsForContext`, for three subjects. Both fixtures make the seed, fold, clamp and annotate steps all fire. ## H3 — resolutions per write - Bulk update across N matched rows: 1 resolution for N=1 and for N=25. - Batch insert of 7 rows: 1 resolution. - Two writes: 2 resolutions, and a grant revoked between them is refused on the second, so nothing is kept across writes. - A write whose gates never call `can` makes 0 resolutions, even with a resolver that would throw. A system write makes 0. The set resolution under the member is plugin-security's existing per-context memo, keyed on the request's context object and retired by the write epoch. Within one request context a second ask costs no second set load. A new context loads again (pinned). ## Both failure directions (pinned) - Resolver throws: the insert rejects with that very error object (`code`, `status` intact), it is not a `ValidationError`, and nothing is written. - Off-shape map (`{ crm_account: true }`): `TypeError` from `toEvalPermissions`, nothing written. - No resolver: admitted, with one warn whose `meta.error.message` contains `carries no permission data`. It is not denied. **Ablation:** the insert call site's `permissions: insertPermissionsFor(rows[i])` was replaced by `permissions: undefined /* ABLATION-18783 */` through `scripts/ablation-replace.mjs`. The anchor went 1 to 0, the marker 0 to 1, and the blob changed. - 5 engine cases went red (refuse, admit, throw fails closed, off-shape map, revocation); the 8 control and non-insert cases stayed green. - The file was restored to its HEAD blob and `git diff HEAD` was empty. - The first attempt used a replacement already present 178 times. The tool refused it (count unchanged) and restored, so it was a no-op and was re-run with the marker. ## Known gap — measured, not changed here `can()` reads only per-object entries. `/auth/me/permissions` materialises an entry for an object covered by `'*'` only when that wildcard carries a super-user bit (the seed pass). With the shipped `organization_admin_no_bypass` plus `member_default` (the grant a deployment without an organization wall gives organization owners and admins): - the map has no `crm_account` entry; - `current_user.can('crm_account', 'edit')` evaluates to `false`; - `PermissionEvaluator.checkObjectPermission('update', 'crm_account', sets)` is `true`. `admin_full_access` and walled `organization_admin` answer `true` on both sides. Before this PR that population's `can` gate was never enforced for anyone. From this PR on, the server refuses them on a `can`-gated option. The fix belongs to the map's producer (materialising plain-wildcard coverage, which changes the `/auth/me/permissions` response) or to formula's `can`. It is raised as a question in the report, not decided here. ## Overlap with in-flight work `rule-validator.ts`: this diff touches the `EvaluateRulesOptions` interface (one new member), the `USER_SCOPE_ROOTS` docblock, the `evaluateOptionVisibility` region (a new picker plus helpers), and ONE line inside `evaluateValidationRules`'s body: the `evaluateOptionVisibility(...)` call gains `opts.permissions`. That call is not the function's head, the `requiredWhen` / `readonlyWhen` arms or `unevaluableRuleError`, which are draft PR objectstack-ai#20028's region. `traversalRefusal` (PR objectstack-ai#20049) is already on `main` and was merged in here without a conflict. ## Verification (at `f3fe6d6cfd`) - Suites: objectql `test` 312 files / 5292 tests and `test:repo` 1/5; plugin-security 134 / 2658; plugin-hono-server 27 / 313 (+1 todo); core `test` 53 / 1331 and `test:repo` 3 / 48. All passed. The objectql, plugin-hono-server and core runs are from the merge commit `b5416a2b49`; the two later commits touch only plugin-security's new test file, and plugin-security's suite and typecheck were re-run at `f3fe6d6cfd`. - `typecheck` passed for core, objectql, plugin-security and plugin-hono-server. Each new test file is in a tsc program (`--listFiles`, via `tsconfig.test.json` or the main config). - The four packages build (`check-dts-emitted` present). CJS and ESM load probes see the new core exports and the hono re-exports. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 72 commands, and all were run at `f3fe6d6cfd`. 69 exited 0. - 3 exited 3 with PREREQUISITE NOT MET, so they are NOT MEASURED: `check:dual-build-cjs-loads`, `check:type-check-debt` (both need the whole workspace built) and `check:i18n` (needs the CLI build closure). `--ran` reconciliation: 72 derived, 69 run, 3 NOT-MEASURED, 0 UNRUN. - `check-issue-citations --base b76aad5`: every citation this change adds resolves. - Narrowed eslint (`--no-inline-config`, `--format json`) on the 11 changed `.ts` files: 11 files, 0 errors, 0 warnings. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so the diff cannot move a verdict on an untouched file. ## Acceptance notes - plugin-hono-server's pin batteries for the four folds (`fold-wildcard-superuser.test.ts`, `effective-api-operations.test.ts`) stay where they are. They exercise the folds through that package's unchanged re-exports. core carries its own composition pins. - The route's failure stance is unchanged: a set-resolution failure still answers `objects: {}` (its pre-existing `.catch(() => [])`). The member's stance differs on purpose: it throws. - `check-changeset-no-major`'s Clause-② level axis reports NOT APPLICABLE locally (there is no `pull_request` payload). CI reads it. ## Seat amendment (5825601266) - `Clause-②` is corrected from the claim's `no` to `yes (widening)`. The diff adds public exports to `@objectstack/core` (`buildEffectiveObjectPermissions`, plus the folds and types moved from `plugin-hono-server`) and a public `ObjectQL.registerEffectiveObjectPermissionsResolver`. - The widened file surface (`packages/core`, and `plugin-hono-server`, which is `domain:cli` surface) is accepted as the single-producer consequence of the member's "computed once" rule. - The open questions are answered A (land, and fix the plain-wildcard gap at the producer, as its own card), B (this line) and A (keep `Fixes`; the value-expression rows are filed as their own card). - objectstack-ai#18783 is re-graded to p1 per ruling A's census clause. ## Patch round 1 (merge-queue failure, `7a6b091b2a`) - **What failed:** the queue removed this PR on `Test Core (1/6)` / `Spec property liveness` (queue run 36088336600). The spec liveness gate reported `permission/objects.allowExport` UNANCHORED, because its evidence cited `plugin-hono-server/src/current-user-endpoints.ts`, which, after this PR moved `annotateEffectiveApiOperations` into `@objectstack/core`, no longer names `allowExport` (0 mentions; the new file has 7). PR CI runs only the affected subset, and the queue runs the full suite. - **Fix:** one ledger file, `packages/spec/liveness/permission.json`. The citation is repointed to `packages/core/src/security/effective-object-permissions.ts#annotateEffectiveApiOperations`, with `verifiedAt` 2026-09-25 and a dated note. It is a declared cross-lane pointer update in the spec LEDGER, not in `src`. - `check:liveness` went from exit 1 (`1 UNANCHORED`) to exit 0. - `check-liveness.test.ts` went from 20 failed / 44 passed to 64 passed. - The sweep for other pointers made false by the move found none. The two governed ADR mentions (0103, 0124) are still true and were not edited. - The delta contract review is recorded against this head. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20007
Clause-②: no
What was wrong
An author who wants to refuse an order whose OPTIONAL
linelookup points at a secret line, and say nothing about an order with no line, was sent in a circle by two refusals:record.line != null && record.line.kind == 'secret'readslineboth through the relationship and as a plain value, so the formula conflict check refuses it (in@objectstack/lintat authoring time and in the engine at write time). That refusal prescribed "Compare the id explicitly: writerecord.line.idfor the value comparison".record.line.id != null && record.line.kind == 'secret'also reads throughline. So an order with no line is refused before evaluation as "no single related record" (resolveTraversalScopegets ano-referencebinding). That prescription said "Guard the rule on the reference being set, make it required, or …" and named no spelling for the guard.The spellings that work, a
conditionalwrapper orrequired: true, were named in neither refusal.What changed
Only the prescription text. Every refusal keeps its verdict, its
VALIDATION_FAILEDerror, itsrule_violationfield error and itsconstraint, and the same writes are refused.packages/formula/src/relationship-traversal.ts: thebare-and-traversedmessage now says.idcompares ids and is not a null guard. For a plain value that tests for empty, it names the guard andrequired: true.packages/objectql/src/validation/rule-validator.ts, theno-referencearm oftraversalRefusal: names the guard through the landedreferenceGuardRepair(field), saysrecord.FIELD.id != nullis no guard, and namesrequired: true. The multi-value macro clause is kept.Engine texts at this head, printed from the built
@objectstack/objectqldist. The runtime text spellsRELATED_FIELDbelow as the placeholderrelated fieldin angle brackets.Premise check and PM hypotheses (measured at
origin/mainb3735968ba)#20007block inengine-predicate-relationship.test.tswas committed first (ae97576f7) and run against the unchanged source:2 failed | 2 passed. Step 1: the natural spelling is refused as the mixed shape, and the text lacked the guard. Step 2:record.line.id != null && …on an emptylineis refused before evaluation with a fault that endsno single related record, and the text named no spelling. Both failures were on text assertions. The code, field code and constraint assertions above them passed.conditionalrule withwhen: 'record.line != null'wrappingrecord.line.kind == 'secret'accepts an insert with no line,line: nulland a public line. It refuses a secret line with the rule's own message (field_record,rule_violation, nounevaluableconstraint). On update, it refuses repointing an empty order at a secret line and accepts clearing a set one.required: true, an insert with no line is refused with exactly one field error,['line', 'required'], because the rule is never reached. A public line is accepted and a secret line is refused by the rule.ValidationRule(@objectstack/spec/data,safeParseok). Control: the same wrapper without itsmessagefails withinvalid_typeatmessage.findTraversalConflictshas two callers.validateExpression(formula) is used by@objectstack/lint'svalidateStackExpressionsatrule.condition, the only site that passestraversalHydration: true. Its output text changes. A probe against the built lint dist gave: the natural spelling, one error with the new text; the.idspelling, 0 issues (lint cannot know a reference will be empty); the wrapped rule, 0 issues.resolveTraversalScopepasses the message through intodetail, so the engine text changes.packages/lint/distandpackages/objectql/dist. Control: 2 hits inpackages/formula/dist. The text ships from formula alone.@objectstack/formulawould add a public export. That would widen the public surface, so the claim'sClause-②: nowould not hold. So the wording exists twice, and a test holds the copies equal:referenceGuardRepair(root, field), not exported;referenceGuardRepair(field).engine-predicate-relationship.test.tsasserts one literalGUARDin the engine refusal worded by formula (step 1) and in the one worded by objectql (step 2).Tests (at
0f4c443ac)@objectstack/formula:vitest run35 files / 978 passed;typecheckexit 0.@objectstack/objectql:--project local311 files / 5266 passed (run atb8801356b; since then only one test's assertions changed, and that file re-ran 42/42 at this head);test:repo5/5;typecheckexit 0 (check:test-typecheckOK, ledger unchanged).@objectstack/lint(consumer):vitest run108 files / 4138 passed, with formula rebuilt.validateExpressionrefusal name the guard andrequired: true, and both halves of the guarded rule validate;no-referencecase and the mixed case name the guard;#20007cases above.dist, because the objectql to formula import is an unaliased pair inKNOWN_UNALIASED_TEST_IMPORTS).scripts/ablation-replace.mjsrewrotewhose `when` is `toIS_ABLATEDin the formula copy only: anchor 1 to 0, blob002192dc9337to324bfd327a45. After a formula build,ablation-dist-preflightreported the marker present in 2 built files. Results:1 failed | 3 passed);2 failed | 30 passed).git diff HEADis empty. After a rebuild, the marker is absent from all 6 built files and the tree is clean. The pins are green again: objectql 4/4, formula 32/32.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat0f4c443ac(merge baseb3735968b) derives 63 commands; 61 exit 0.pnpm check:dual-build-cjs-loadsandpnpm check:type-check-debt. Both read the whole workspace's builtdist, which needs a fullpnpm buildthat this seat did not run on the shared box. This diff changes string literals and tests only. CI builds and runs both.--ran:63 derived famil(ies) accounted for — 61 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3).check:error-code-casingfirst went red on a new test line (toMatchObject({ code: 'rule_violation' })). The assertion now uses the envelope shape the neighbouring pins use, and the gate exits 0.node scripts/check-issue-citations.mjs --base b3735968b: exit 0, 4 citations resolve.pnpm check:nul-bytes: exit 0.0f4c443ac:eslint --no-inline-config --format jsonover the 5 changed.tsfiles gives 5 files, 0 errors and 0 warnings.--print-configshows each file is in the population withparserOptions.projectandprojectServicenull.eslint.config.mjsstates that type-aware linting is never enabled, so this diff cannot move a verdict on any untouched file. The fullpnpm lintrun belongs to CI.Acceptance notes
.changeset/18682-predicate-relationship-traversal.mdtable still reads "record.account.id == 'acc_1'for the value comparison". That is correct for its example, which is an id comparison and not a null test, so it is left alone.no-referencearm still names no repair of its own. It is unchanged here and outside this card.ValidationErrorcarriescode: 'VALIDATION_FAILED'andfields, and nostatus. The REST layer maps the status, and this PR does not change it.requiredWhen/readonlyWhenfault arms,unevaluableRuleError,packages/lint/src/validate-null-guards.ts(draft PR fix(objectql)!: a field-level requiredWhen / readonlyWhen that cannot be evaluated refuses the write (ADR-0137 D2) #20028) andpackages/spec.@objectstack/formulapatch and@objectstack/objectqlpatch.Generated by Claude Code