Skip to content

fix(formula,objectql): name the working guard for an optional lookup in both traversal refusals - #20049

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20007-optional-lookup-guard-prescription
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20007-optional-lookup-guard-prescription

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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.

[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 #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 #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 b3735968b: 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 fix(objectql)!: a field-level requiredWhen / readonlyWhen that cannot be evaluated refuses the write (ADR-0137 D2) #20028) and packages/spec.
  • Changeset: @objectstack/formula patch and @objectstack/objectql patch.

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 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
  • 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 — 21 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 5581d3000f27daa19991cf9d13d5ad1ed8cf8913 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 990cc7a9bf6157084e90baab30fcf44ec1e2813e — the merge of head 0f4c443ac9ed56c9d18ba5af465cd23c0af7ee29 into base 5581d3000f27daa19991cf9d13d5ad1ed8cf8913, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0f4c443ac9ed56c9d18ba5af465cd23c0af7ee29

Scope read: card #20007 (body and all 6 comments; the dev's os-dev-report 5823964281 read as claims), the cited record 5795813514 (its P4), the PR body, the whole diff b3735968ba..0f4c443ac (6 files, +284/−5; merge base verified), the 34 check-runs, AGENTS.md, and the code at head.

Measured in two detached worktrees, one at head and one at the merge base, both removed afterwards:

  • one probe driven against each worktree's built dists: 45 engine cells, 6 lint stacks, 3 formula calls and 3 spec parses;
  • the PR's three pin files;
  • an H1 reverse run;
  • two ablations, each with a blob-proven restore.

① Derived judgments

  • Truth of the new sentences for every shape that reaches them: RIGHT.
    • Every record.-rooted shape that reaches either text gets true sentences: the mixed shape on insert, the .id spelling on an empty line, and clearing a set line on update. Both texts carry the guard literal and required: true.
    • The engine and lint analyse only the record root, so a previous.-rooted rule never meets either text. The objectql arm is unreachable from a previous. traversal.
    • Multi-value and expanded references reach the objectql arm. The guard advice is scoped by "To skip the rule while line is empty", and the macro clause is kept.
  • The wrapper and required: true, end to end on insert and update, identical at head and base: RIGHT.
    • The wrapper accepts no line, null and a public line, and refuses a secret line with the rule's own message. On update it refuses empty→secret and public→secret, and accepts clearing to null.
    • required: true refuses a missing or null line with exactly ['line','required'].
  • Refused set and envelope unchanged: RIGHT.
    • The verdict is identical at base and head in all 45 cells.
    • In all 31 refused cells, code, every fields[].field / code / constraint is identical.
    • constraint is { rule, reason: 'unevaluable', fault }, and fault is the unchanged summary; it does not carry the detail text. Only message differs, in exactly the 14 cells that carry a rewritten text.
  • The two copies of the guard sentence are held equal: RIGHT.
  • Public surface: RIGHT. The built .d.ts files of formula and objectql are byte-identical base vs head. referenceGuardRepair is module-local in each package. No generated runtime string carries a tracker number.
  • Consumers: RIGHT.
    • findTraversalConflicts has 3 non-test source hits: its definition, formula validate.ts and objectql resolveTraversalScope.
    • traversalHydration is set true at exactly one production site, lint validate-expressions.ts. Lint's issue text is byte-equal to formula's.
    • 0 tests assert the old texts outside the three edited suites.
  • CI at this head: 34 runs, 31 success, 3 skipped, 0 failures, and all seven required contexts are success.

② Semver level

patch on @objectstack/formula and @objectstack/objectql, with Clause-②: no and packages/spec untouched: RIGHT. The accept set is unchanged (45/45 verdicts, every code and constraint identical), both .d.ts are byte-identical, and a corrected prescription is a bug fix.

③ Boundary flags

Changeset: every sentence is TRUE:

  • the title and the circle narrative; items 1 and 2 quote the base text verbatim;
  • "Which writes are refused is unchanged" (45/45);
  • "the error, the rule_violation field error and its constraint" holds for every machine-readable member, and "Only the prescriptions change" covers message;
  • lint passes the text through;
  • the guard is worded exactly as the delete-cleanup refusal's sentence;
  • both text blocks are suffixes of the generated strings;
  • the TS example parses as ValidationRuleSchema, and the engine and lint accept it as written;
  • the required: true sentence.

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:

  • formula's public root parameter reaches the guard's when. A caller passing root: 'previous' would be told a wrapper that does not work, but no in-repo caller passes a non-default root, and neither lint nor the engine can show that text.
  • On a multi-value lookup, "empty" is [], which the != null guard does not skip. The kept macro clause and required: true are the operative repairs there.
  • An already-expanded record names no repair of its own. That is unchanged from base and disclosed.

Implemented-by: claude/issue-20007-optional-lookup-guard-prescription
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

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.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 24, 2026 23:53
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit 7465eeb Sep 25, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20007-optional-lookup-guard-prescription branch September 25, 2026 00:24
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
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/m tests tooling

Projects

None yet

1 participant