fix(objectql)!: a field-level requiredWhen / readonlyWhen that cannot be evaluated refuses the write (ADR-0137 D2) - #20028
Conversation
…ADR-0137 D2) [wip] Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
…oduces (ADR-0137 D2) [wip] Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
…ns it retires [wip] Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
… the prose-id baseline the lint rewrite burned down [wip] Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
…-predicate-fault-submit-refuses
📓 Docs Drift CheckThis PR changes 2 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
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 282c99d4c674462d783bfca412d1d5ff302a9a21 && git checkout 282c99d4c674462d783bfca412d1d5ff302a9a21
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 44639665eea4adc6de1eaa963ccd0e5a3e875f98 7a6e91f5fd35ef92a86bdca202b87cc485bd045a && git checkout -B drift-repro 44639665eea4adc6de1eaa963ccd0e5a3e875f98 && git merge --no-ff 7a6e91f5fd35ef92a86bdca202b87cc485bd045a
node scripts/docs-audit/affected-docs.mjs --json 44639665eea4adc6de1eaa963ccd0e5a3e875f98
|
Contract reviewServed-tier: 162/162 Isolated at-tier reviewer subagent, run by the Inputs read: card #19727 body and all 7 comments (5777755280, 5788210552, 5788580082, 5789283997, 5808379899, 5819444783, os-dev-report 5821543547); ① Derived judgments(a) Scope — implements D2 on exactly the two ruled arms.
(b) Loud and complete; nothing persisted.
(c) Open question 1 — D2 read literally is what D2 says. ADR-0137 D2 at (d) Extra files — each forced by the change, none creep.
(e) Semver and markers.
(f) Census — verified myself. (g) Pins weight-bearing. Ablations not re-run (⛔). By assertion reading: (a) cases assert the throw AND the store unchanged, so restoring ② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL — one blocking item: PR body line 2 Generated by Claude Code |
Contract reviewServed-tier: 196/196 Isolated at-tier reviewer subagent, run by the Re-check of the same head (unchanged: ① Derived judgments(a) Scope — D2 on exactly the two ruled arms. (b) Loud and complete; nothing persisted. Envelope (c) Open question 1. ADR-0137 D2 verbatim: "At submit time, a field-rule predicate that cannot be evaluated refuses the write and names the field and the rule. Nothing is persisted." … "refusing is the only answer that neither invents a verdict nor hides that one is missing." No clause conditions the refusal on the field being empty or changed; the literal implementation ( (d) Extra files — each forced, none creep. (e) Semver and markers — now consistent.
(f) Census — verified. (g) Pins weight-bearing. Ablations not re-run (⛔). The assertions discriminate what the dev's table claims: (a) cases assert throw + store unchanged (A1); (b) cases assert throw + row unchanged (A2); the ok-first order is the only case ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS — the single blocking item from the prior record is cleared on the unchanged head: PR body, changeset and claim now agree under their respective grammars, and Generated by Claude Code |
…in both traversal refusals (objectstack-ai#20049) Fixes objectstack-ai#20007 Clause-②: no ## What was wrong An author who wants to refuse an order whose OPTIONAL `line` lookup points at a secret line, and say nothing about an order with no line, was sent in a circle by two refusals: 1. `record.line != null && record.line.kind == 'secret'` reads `line` both through the relationship and as a plain value, so the formula conflict check refuses it (in `@objectstack/lint` at authoring time and in the engine at write time). That refusal prescribed "Compare the id explicitly: write `record.line.id` for the value comparison". 2. `record.line.id != null && record.line.kind == 'secret'` also reads through `line`. So an order with no line is refused before evaluation as "no single related record" (`resolveTraversalScope` gets a `no-reference` binding). 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 `conditional` wrapper or `required: true`, were named in neither refusal. ## What changed Only the prescription text. Every refusal keeps its verdict, its `VALIDATION_FAILED` error, its `rule_violation` field error and its `constraint`, and the same writes are refused. - `packages/formula/src/relationship-traversal.ts`: the `bare-and-traversed` message now says `.id` compares ids and is not a null guard. For a plain value that tests for empty, it names the guard and `required: true`. - `packages/objectql/src/validation/rule-validator.ts`, the `no-reference` arm of `traversalRefusal`: names the guard through the landed `referenceGuardRepair(field)`, says `record.FIELD.id != null` is no guard, and names `required: true`. The multi-value macro clause is kept. Engine texts at this head, printed from the built `@objectstack/objectql` dist. The runtime text spells `RELATED_FIELD` below as the placeholder `related field` in angle brackets. ```text [mixed shape, worded by formula] … To compare the id, write `record.line.id` for the value comparison, and keep `record.line.RELATED_FIELD` for the traversal. `record.line.id` is not a null guard: it reads through `line` too, and a rule that reads through an empty `line` rejects the write instead of being skipped. If the plain value tests for empty, take that test out of this expression. To skip the rule while `line` is empty, guard it on `line` being set: make it the `then` of a `conditional` rule whose `when` is `record.line != null`. To refuse an empty `line`, make `line` required (`required: true`). [no single related record, worded by objectql] … A predicate resolves ONE hop through a single reference. To skip the rule while `line` is empty, guard it on `line` being set: make it the `then` of a `conditional` rule whose `when` is `record.line != null` — `record.line.id != null` inside the rule is no guard, as it reads through `line` too. To refuse an empty `line`, make `line` required (`required: true`). For a multi-value reference, test it with a macro (`exists`, `size`) instead of reading through it. ``` ## Premise check and PM hypotheses (measured at `origin/main` `b3735968ba`) - **H1 holds.** The new `objectstack-ai#20007` block in `engine-predicate-relationship.test.ts` was 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 empty `line` is refused before evaluation with a fault that ends `no 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. - **H2 holds.** Two tests were green on the unchanged source, so the text change is all this card needed: - A `conditional` rule with `when: 'record.line != null'` wrapping `record.line.kind == 'secret'` accepts an insert with no line, `line: null` and a public line. It refuses a secret line with the rule's own message (field `_record`, `rule_violation`, no `unevaluable` constraint). On update, it refuses repointing an empty order at a secret line and accepts clearing a set one. - With `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. - The wrapper parses as a spec `ValidationRule` (`@objectstack/spec/data`, `safeParse` ok). Control: the same wrapper without its `message` fails with `invalid_type` at `message`. - **H3, the consumers of the formula text:** `findTraversalConflicts` has two callers. - `validateExpression` (formula) is used by `@objectstack/lint`'s `validateStackExpressions` at `rule.condition`, the only site that passes `traversalHydration: true`. Its output text changes. A probe against the built lint dist gave: the natural spelling, one error with the new text; the `.id` spelling, 0 issues (lint cannot know a reference will be empty); the wrapped rule, 0 issues. - ObjectQL's `resolveTraversalScope` passes the message through into `detail`, so the engine text changes. - Lint and objectql dists do not bundle the formula text: 0 hits for it in `packages/lint/dist` and `packages/objectql/dist`. Control: 2 hits in `packages/formula/dist`. The text ships from formula alone. - Repo-wide grep for tests asserting the old texts ("Compare the id", "no single related record", "MULTIPLE references", "make it required", "value comparison" and more): hits only in the three suites this PR edits. No lint test asserts the formula text. - **H4, where the one wording lives:** formula may not import objectql, and exporting the sentence from `@objectstack/formula` would add a public export. That would widen the public surface, so the claim's `Clause-②: no` would not hold. So the wording exists twice, and a test holds the copies equal: - formula has a module-local `referenceGuardRepair(root, field)`, not exported; - objectql keeps its landed module-local `referenceGuardRepair(field)`. - Each docblock names the other copy and the pin. `engine-predicate-relationship.test.ts` asserts one literal `GUARD` in the engine refusal worded by formula (step 1) and in the one worded by objectql (step 2). - Ablation below: mutating only the formula copy turns step 1 red and leaves step 2 green. ## Tests (at `0f4c443ac`) - `@objectstack/formula`: `vitest run` 35 files / 978 passed; `typecheck` exit 0. - `@objectstack/objectql`: - `--project local` 311 files / 5266 passed (run at `b8801356b`; since then only one test's assertions changed, and that file re-ran 42/42 at this head); - `test:repo` 5/5; - `typecheck` exit 0 (`check:test-typecheck` OK, ledger unchanged). - `@objectstack/lint` (consumer): `vitest run` 108 files / 4138 passed, with formula rebuilt. - New pins: - formula: the conflict message and the `validateExpression` refusal name the guard and `required: true`, and both halves of the guarded rule validate; - objectql rule level: the `no-reference` case and the mixed case name the guard; - objectql engine end to end: the four `objectstack-ai#20007` cases above. - **Reverse verification:** the 2 red / 2 green run on the unchanged source is quoted under H1. - **Ablation** (through `dist`, because the objectql to formula import is an unaliased pair in `KNOWN_UNALIASED_TEST_IMPORTS`). `scripts/ablation-replace.mjs` rewrote `` whose `when` is ` `` to `IS_ABLATED` in the formula copy only: anchor 1 to 0, blob `002192dc9337` to `324bfd327a45`. After a formula build, `ablation-dist-preflight` reported the marker present in 2 built files. Results: - objectql step 1 red, steps 2 to 4 green (`1 failed | 3 passed`); - the two new formula text pins red (`2 failed | 30 passed`). - **Restore:** the blob equals HEAD and `git diff HEAD` is 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/objectstack` at `0f4c443ac` (merge base `b3735968b`) derives 63 commands; 61 exit 0. - **NOT MEASURED** (exit 3, PREREQUISITE NOT MET): `pnpm check:dual-build-cjs-loads` and `pnpm check:type-check-debt`. Both read the whole workspace's built `dist`, which needs a full `pnpm build` that 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-casing` first 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 b373596`: exit 0, 4 citations resolve. - `pnpm check:nul-bytes`: exit 0. - Narrowed ESLint at `0f4c443ac`: `eslint --no-inline-config --format json` over the 5 changed `.ts` files gives 5 files, 0 errors and 0 warnings. `--print-config` shows each file is in the population with `parserOptions.project` and `projectService` null. `eslint.config.mjs` states that type-aware linting is never enabled, so this diff cannot move a verdict on any untouched file. The full `pnpm lint` run belongs to CI. ## Acceptance notes - The pending `.changeset/18682-predicate-relationship-traversal.md` table 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. - The shape "already holds an expanded record rather than an id" in the `no-reference` arm still names no repair of its own. It is unchanged here and outside this card. - The engine's `ValidationError` carries `code: 'VALIDATION_FAILED'` and `fields`, and no `status`. The REST layer maps the status, and this PR does not change it. - Out of this PR's surface, per the claim: `requiredWhen` / `readonlyWhen` fault arms, `unevaluableRuleError`, `packages/lint/src/validate-null-guards.ts` (draft PR objectstack-ai#20028) and `packages/spec`. - Changeset: `@objectstack/formula` patch and `@objectstack/objectql` patch. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…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 #19727
Clause-②: no (narrowing)
What this does
ADR-0137 D2 (Accepted), verbatim: "At submit time, a field-rule predicate that cannot be evaluated refuses the write and names the field and the rule. Nothing is persisted." Triage's execution reading on the card (comment 5788580082) puts the server-side
requiredWhen/readonlyWhenunder D2, and this PR is the server half. Its scope is the two arms ADR-0137's own Context measured as fail-open:requiredWhen(rule-validator.ts:3026-3044at base61609edf81): the arm loggedrequiredWhen for 'X' failed to evaluate — skippedand rancontinue, so the record saved with the field empty. It now refuses. Every fault takes this arm, including an unboundparent(a master-detail header that could not be resolved for the write). The refusal goes into the call'sValidationError, which is thrown before any driver call.readonlyWhen(rule-validator.ts:1350-1351at base, insideisReadonlyWhenLocked): the non-root arm loggedchange allowed throughand returnedfalse, so the field was written. It now throws aValidationErrorfrom the strip, before anything is written. The unbound-root carve-out is unchanged and still LOCKS.Both arms go through the refusal builder that
checkPredicatehas used since the #4649 work.unevaluableRuleErrorgained asubjectparameter, so the envelope stays the same:VALIDATION_FAILED(served as 400), one entry withfieldset to the field,code: 'rule_violation', andconstraint: { rule: 'requiredWhen' | 'readonlyWhen', reason: 'unevaluable', fault, missingKey? , hint? }. The message readsField 'X' requiredWhen could not be evaluated (...) — write rejected.The operator also gets awarnline saying the write was rejected.Details:
readonlyWhenchecks every matched row. Before, the check stopped at the first row that locked (some). Now a row that faults refuses the write no matter where it sits among the matched rows, so the verdict no longer depends on the order the driver returns rows in. The refusal names the row ((record ID)), the same way the bulk validation refusal does. Warnings are said once per key.record.account.tier) faults asNo such key: tier, because field-level predicates are never hydrated. The generic sentence would tell the author to declaretieron the wrong object. The refusal instead says the column is read throughaccount, a reference tofp_account, and points the author at avalidations[]scriptrule. An unboundparentalso gets its own sentence.@objectstack/lintbuild-time messages. Three author-facing strings said the server skips a faultingrequiredWhen, and this change makes them false: the unbound-root slot consequence, theparent-without-a-master consequence, and the null-guard outcome, which switches from'fail-open'to'fail-closed'. All three now say the write is refused. Thedoc-authoringprose-id baseline shrank by the two tracker ids those strings dropped. That is the gate's own prescribed remedy.Not changed here, on purpose:
visibleWhen(evaluateOptionVisibility), which D2 does not name and which stays fail-open;requiredWhen.packages/specis untouched.Census: stored predicates that could start refusing
I checked every
requiredWhen/readonlyWhenunderpackages/**,examples/**andapps/**, excluding tests, at7a6e91f5fd. I evaluated each predicate with the realExpressionEngineover normal and null bindings.examples/app-showcaseinvoiceissued_onrequiredWhen: record.status in ['sent', 'paid']false)tax_ratereadonlyWhen: record.status == 'paid'paid_onrequiredWhen: record.status == 'paid'descriptionrequiredWhen: record.quantity >= 100quantity.quantityisrequiredwithdefaultValue: 1. Measured on the engine: an insert without it gets the default and is accepted, and an update that nulls it is refused byrequiredfirst. So no normal write reaches the faultproduct/quantity/unit_pricereadonlyWhen: parent.status == 'paid'plugin-securitysys_permission_set.namereadonlyWhen: record.id != null && record.id != ''Result: none of the in-repo predicates start refusing.
driver-sql'sbuiltin-column-collision.tsnames the keys as a classification map and holds no predicate. How many stored predicates in deployments fault is unmeasured. ADR-0137's Consequences says the loud state is what reveals them, and the changeset says so.Pins, and the ablation that turns each red
The new file is
packages/objectql/src/engine-field-predicate-fault.test.ts. It uses the real engine and an in-memory driver, and reads "nothing persisted" off the store. It has 13 cases.scripts/ablation-replace.mjs, WRAP mode)continuereturn falsebefore the throwrequiredvisibleWhenstays fail-openFor every leg, the anchor hit x1 and went to x0, the blob changed, and the restore was proven: blob
5bfc176263equals HEAD andgit diff HEADis empty. The script also carried an EXIT/INT/TERM trap that restores by absolute path. No build was needed: the subject resolves through relative source imports (./engine.js→./validation/rule-validator.js), not throughdist/.Existing pins that locked in the retired fail-open branch were flipped. Each still pins its original subject:
rule-validator.test.ts(7 cases);rule-fail-closed.test.ts(the requiredWhen "neighbour");engine-required-when-parent.test.ts(orphan header, 3 cases);engine-readonly-when-parent.test.ts(undeclared header key; the threenot change allowed throughabsence checks were retargeted to the refusal channel so they are not vacuous);engine-readonly-when-interdependent-locks.test.ts(a faulting FK lock);engine-reference-tenant-scope.test.ts(4 cases). The invariant these pin, that a locked, an open and a missing header give one answer, holds. The answer is nownote: rule_violationfor all of them.One fixture was re-spelled:
rule-validator.test.ts"accepts the write once the field is supplied" now suppliesquantity. Its unrelatedrecord.quantity >= 100rule had been faulting silently.Test readings
@objectstack/objectqlat7a6e91f5fd: 313 files, 5256 tests passed.typecheckexits 0, andcheck:test-typecheckholds its ledger.@objectstack/lintat7a6e91f5fd: 108 files, 4138 tests passed.typecheckexits 0.@objectstack/runtimeat7a6e91f5fd: 276 files, 3893 passed, 1 skipped.@objectstack/plugin-securityatb25e969e1d: 128 files, 2501 passed. It reads@objectstack/objectqlthrough adist/built from the samerule-validator.ts.@objectstack/restatb25e969e1d: 194 files, 3265 passed, 1 skipped. Samedist/as above.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat7a6e91f5fdderived 72 commands. I ran all 72: 71 exit 0.pnpm check:dual-build-cjs-loadsexits 3 (PREREQUISITE NOT MET, needs a fullpnpm build), so it is NOT MEASURED locally.--ranreconciliation: 72 derived, 71 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0 UNRUN.check:adr-0087-registrationis green. The changeset reads asBREAKING+bang+clause-②-narrowing, and its disposition is not-required (no-migration-prescription): nothing authored moves and no stored shape is refused.check:objectql-double-limitwent red once, on the new test'sfinddouble, and is green now that the double honourslimit.check:doc-authoringwent red once, on a stale over-pin, and is green after the prescribed baseline shrink.Narrowed lint, instead of the CI-owned
pnpm lint: I raneslint --no-inline-config --format jsonon the 13 touched.tsfiles. Result: 13 files, 0 errors, 0 warnings, none ignored. All 13 fall under the**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}config object (eslint.config.mjs:971). That config never enables type-aware linting (noparserOptions.project, per its own note ateslint.config.mjs:327), so this diff cannot move the verdict on an untouched file.Acceptance notes
requiredWhenwhose predicate faults refuses even a write that supplies the field, and areadonlyWhenfault refuses even an echoed, unchanged value. There is a measured alternative. CEL absorbs an error in&&(record.statsu == 'closed' && record.reason == nullevaluates tofalsewhenreasonis set). So the same rule written as avalidations[]script refuses only when the field is empty. The report puts this to the seat as an open question and recommends staying literal.evaluateValidationRulesruns beforeassertReferencesResolve. A detail whose header FK does not resolve, and that carries aparent-scopedrequiredWhen, now meets the requiredWhen refusal ("no header could be resolved") beforeheader: reference_not_found. Both are loud, and the tenant-scope no-leak invariant holds.validate-expressions.tspassestraversalHydrationfor validation rules only). The census found none in this repository.NullGuardOutcome's'fail-open'member has no caller, and its clause still describes the oldrequiredWhenruntime. Its doc comment says so. It is not removed here.rule-validator.ts, its tests,.changeset/). It addspackages/lint/**,packages/objectql/src/{cel-fault,master-detail}.ts(docblocks this change made false), five morepackages/objectql/src/*.test.tspin files, andscripts/doc-authoring-prose-id.baseline.json.Generated by Claude Code