Skip to content

feat(formula): register can receiver-only and answer it from permission data in EvalContext - #18781

Merged
huangyiirene merged 6 commits into
mainfrom
claude/issue-18545-formula-can-receiver
Sep 17, 2026
Merged

huangyiirene merged 6 commits into
mainfrom
claude/issue-18545-formula-can-receiver

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes #18545

Clause-②: yes

Execution of the maintainer ruling on batch #147 item 5, letter A (comment 5715685109), which supersedes the earlier 停放 (5715643708). Nothing in the shape was re-decided here.

The defect this closes

Registering can alone would make the publish gate pass current_user.can(object, verb) with zero errors while nothing could evaluate it — the production evaluator receives { now, timezone, user, org, record } and EvalUser carries no permissions. The author's predicate publishes green and faults on every screen at runtime (the #13594 regression, recorded at packages/lint/src/validate-visibility-predicates.ts). So the acceptance property is a pair, and the test suite asserts both halves: a can predicate given permission data evaluates, and one that is not fails loudly at evaluation.

One trunk, landing together

  1. can registered receiver-only in @objectstack/formula (dyn.can(dyn, dyn): bool). current_user.can(...) resolves; a bare can(x, y) keeps faulting, because a permission question with no subject has no meaning. The name exists either way, so a bare call is reported as a call-FORM fault and not an existence one — the treatment split already gets.

  2. The carriers re-pointed, not deleted. Five of them, each paired with an acceptance case so the move is a reading rather than a deletion:

    • packages/formula/src/unknown-function.test.ts — re-pointed onto canApprove, plus a new case pinning what can answers today.
    • packages/formula/src/validate.test.ts (the "invented method on the canonical user root" case) — re-pointed onto canApprove, plus an ACCEPTS case for current_user.can(record, "read").
    • packages/formula/src/validate.test.ts (the can → min did-you-mean hazard) — re-pointed from the receiver form onto the bare form, which still faults and still lands in that arm. The hint's wording ("not a callable name here") is now literally true of can, which is a receiver-only name.
    • packages/lint/src/runtime-gate.test.ts and packages/lint/src/validate-visibility-predicates.test.ts — same treatment, refusal case plus acceptance case.
    • The prose in packages/lint/src/validate-visibility-predicates.ts keeps the lint: validate-visibility-predicates passes unknown CEL functions clean (the validateExpression premise is falsified) — ruled: extend to function-existence ERROR, scoped supersession of the parse-only ruling #13594 measurement verbatim as the record and gains a SINCE note; it is not rewritten, because that measurement is what the arm rests on.

    Re-measured, not inherited: the dispatch flagged validate.test.ts:289 as measured not to red. Confirmed on this tree — nearestName('can', CEL_STDLIB_FUNCTIONS) still answers 'min', because can is receiver-only and the catalog advertises bare-callables only (cel-stdlib-drift.test.ts case D pins that it must stay out). What DID red is line 278, three cases up, in a different it block.

  3. A real producer of permission data into EvalContext — see below.

The three ruled sub-questions, as implemented

① Pure data map. EvalContext.permissions is a readonly record of object name to EffectiveObjectPermission — the objects map of the published /auth/me/permissions response, unchanged, indexed by object name. No callback resolver: the doc comment on the field states both reasons the ruling gives, the #18318 "declared, never bound" shape and stdlib.ts's purity invariant. The map is pinned when the environment is built, exactly like now, so the same source still evaluates identically on two runs of the same build. It is deliberately NOT mounted as a CEL variable, so a predicate cannot reach around the verb vocabulary to read a raw bit.

② Absent data throws. { ok: false, error: { kind: 'runtime' } }, with a message naming the missing input and the endpoint that supplies it. Never true, never a silent false. An empty map is a different thing and is a real answer (false) — the pin holds the two apart, so the loud refusal is not trained away by firing for every unprivileged caller.

③ Verb vocabulary in @objectstack/spec/security. OBJECT_PERMISSION_VERBS is DERIVED from OBJECT_PERMISSION_KEY_ALIASES' bare verbs — every alias key landing on an allow* bit that is not a can-prefixed spelling of another — so the two can never disagree, and the derived result is pinned exactly in permission.test.ts (a derivation with no pin absorbs an alias-table edit silently). import → allowCreate is the one row that is not derivable and is recorded in the source as the maintainer's own choice, batch #13 — explicitly NOT attributed to ADR-0068, which contains no verb table. Out-of-table verbs are refused loudly and the refusal names the whole vocabulary. restore / purge are absent, as they are on the alias table since their bits were tombstoned.

The producer, and the one place this trunk is not closed in-repo

/auth/me/permissions is the producer, and it already ships. What this PR adds is the path from that response into EvalContext and the loud refusal when nothing walked it: toEvalPermissions(response.objects) parses each entry with the published EffectiveObjectPermissionSchema and refuses a payload that is not that shape, and celEngine.evaluate binds the result. Structurally this is the opposite of #18318 — the field is bound, and any caller that passes it gets a working can with no further wiring in any package.

Declared gap, deliberately not closed here. No in-repo evaluation call site populates permissions yet. The candidates all sit outside the file surface the ruling declared at claim time: packages/objectql's evaluateOptionVisibility and its formula-field evaluation, the seed loader in packages/metadata-protocol, and objectui's renderer (objectui#4421, which the ruling keeps pm:blocked on this card). The first of those additionally needs an effective-permission source the ObjectQL engine does not hold — ExecutionContext.permissions is permission-set NAMES, not object bits — so wiring it would pull in packages/plugins/plugin-security and re-adjudicate architecture this ruling did not cover. Flagged for the seat rather than guessed at.

A gap the ruling left, filled in the enforcement path's direction

The ruling fixed verb → bit; it did not say how a bit is read off an EffectiveObjectPermission. A bare permission[bit] === true answers false for a caller the server lets through — hiding an action from the one administrator who holds the power to use it, which is the declared ≠ enforced defect pointed the dangerous way round. objectPermissionGrants therefore performs the same fold PermissionEvaluator.checkObjectPermission performs: the read bypass on viewAllRecords || modifyAllRecords, the write bypass on modifyAllRecords alone (never create), export as grant ∧ read. Each cell is pinned.

Evidence

All readings taken at 1966c84c, after the final commit and after merging origin/main.

run result
pnpm --filter @objectstack/formula test 32 files / 900 passed
pnpm --filter @objectstack/lint test 104 files / 3879 passed, 5 skipped
pnpm --filter @objectstack/spec test 486 files / 14028 passed
pnpm --filter @objectstack/{formula,lint,spec} typecheck exit 0
pnpm --filter @objectstack/spec check:generated exit 0 (api-surface + export-origins regenerated: 5 added, 0 removed)
pnpm lint (repo-wide, eslint . --no-inline-config) exit 0
derived gate families (scripts/pm/dispatch-gates.mjs --ran) 86 derived, 83 exit 0, 3 NOT MEASURED

NOT MEASURED (3) — check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt, each exit 3 (PREREQUISITE NOT MET: they read a fully built workspace, which is CI's build, not this card's dependency closure). Exit 3 is neither a pass nor a finding.

Reverse verification — both legs, from the committed state

A. Can the ② pin fail? Replaced the loud "no permission data" throw with the forbidden silent return false in packages/formula/src/stdlib.ts. Mutation proved on disk (HEAD blob d7d2c4d8, mutated blob 693f59d5). Run: 1 failed / 20 passed, failing exactly throws when the context carries NO permission data (ruling ②). Restored to blob d7d2c4d8, git diff HEAD empty.

B. Can the acceptance pins fail? Renamed the registered signature so can is not registered, rebuilt @objectstack/formula so the mutation reached the artifact the lint suite consumes, and confirmed with scripts/ablation-dist-preflight.mjs packages/formula 'dyn.can(dyn, dyn): bool' --absent (marker absent from all 6 built files). Run: 2 failed / 213 passed, failing exactly the two acceptance cases in runtime-gate.test.ts and validate-visibility-predicates.test.ts. Restore leg rebuilt and re-ran the pre-flight without --absent (marker back in 2 built files); blob back to d7d2c4d8; git status --porcelain empty across the whole tree.

Both ablation scripts carried trap restore EXIT INT TERM with absolute paths, compared blob hashes rather than reading exit codes, and asserted the anchor count before mutating so a zero-hit edit could not pass as a run.

Changesets

Two, as the ruling requires — skip-changeset would be wrong here, since both packages publish and both grow.

  • @objectstack/formula: minor. New callable name, new EvalContext field, four new exports, one new optional parameter. Purely additive.
  • @objectstack/spec: minor. Five new exports on the security subpath. No schema changes shape.

Neither is breaking, so no **BREAKING** banner and no ADR-0087 disposition is owed; check:adr-0087-registration --base origin/main and check:changeset-no-major --base origin/main both exit 0 on this branch.

Acceptance notes

Observations recorded rather than filed or fixed, per Prime Directive #10:

  • objectPermissionGrants in @objectstack/spec/security now states the super-user fold that PermissionEvaluator.checkObjectPermission in @objectstack/plugin-security implements independently. Two implementations of one rule can drift. Converging the evaluator onto the published helper is the right follow-up and is out of this card's surface; the spec-side doc names the evaluator as the authority it mirrors, and each cell is pinned on this side.
  • packages/lint's buildAccessMatrix folds the same super-user bits a third time, with its own spelling (read: allowRead || viewAllRecords || modifyAllRecords). Same class as above; not filed, since neither is a reproducible defect today and both agree.
  • The ruling and the dispatch call the two formula-side test carriers "the packages/lint carriers". They live in packages/formula/src; packages/lint carries two more. All five were found and re-pointed.

Generated by Claude Code

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/formula, @objectstack/lint, @objectstack/spec, touching 23 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/formula/src/index.ts, packages/spec/api-surface/security.json, packages/spec/export-origins/security.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2085be2b2d8769c6227167bb3525f4fa72b9a486.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/formula/src/index.ts, packages/spec/api-surface/security.json, packages/spec/export-origins/security.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 137 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 2085be2b2d8769c6227167bb3525f4fa72b9a486 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 11a7f33de5f106f6c0e76ba9434ffa95d0475f3b — the merge of head 1966c84cf991796317026d08308445919135f777 into base 2085be2b2d8769c6227167bb3525f4fa72b9a486, 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 11a7f33de5f106f6c0e76ba9434ffa95d0475f3b && git checkout 11a7f33de5f106f6c0e76ba9434ffa95d0475f3b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2085be2b2d8769c6227167bb3525f4fa72b9a486 1966c84cf991796317026d08308445919135f777 && git checkout -B drift-repro 2085be2b2d8769c6227167bb3525f4fa72b9a486 && git merge --no-ff 1966c84cf991796317026d08308445919135f777

node scripts/docs-audit/affected-docs.mjs --json 2085be2b2d8769c6227167bb3525f4fa72b9a486

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

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

Copy link
Copy Markdown
Collaborator Author

needs:contract-review cleared from both carriers — provenance for the stroke, written 2026-09-17T21:35Z.

contract-review record card #18545, comment 5720876458 — ## Contract review, Served-tier: present, VERDICT: PASS
head judged by that record 1966c84cf991796317026d08308445919135f777 — the head this PR still carries
carriers card #18545 cleared · PR #18781 cleared, one stroke, label-write.mjs with read-back on each

Pre-landing checks, all three re-taken at this stroke — ⛔ none carried over.

  • ① the in-seat clause-② review is in case, in the template's shape, at the tier the fuse reads. It also carries the seat's rulings on both of this round's open_questions.
  • ② check-clause2-carriers.mjs --pair 18781 → exit 0, and it reports that a review of record names this head.
  • ③ CI on 1966c84cf9: 35 names, 0 pending, 0 non-green; all seven required contexts green. The five skips — Packed-tarball smoke (opt-in) · Check PR Size · Auto Label · Build Docs · Console Pin Gate — are each on check-expected-skips' roster with a declared reason this diff satisfies. ⭐ Note what is not skipped: Check Changeset ran, because this PR carries no skip-changeset — correctly, since the ruling requires two changesets.

Governed-surface predicate, derived by the script from this PR's own file list: check-governed-merges.mjs --pr 18781 → 0 of 17 paths ⇒ ordinary queue landing.

⏹️ The two rulings this round produced both have carriers before the PR lands, ⛔ not after: #18783 (the server side does not populate EvalContext.permissions, Blocked-by: #18545) and #18785 (the super-user fold is stated three times). ⭐ Each names the other as an affinity and ⛔ neither merges them — that is triage's stroke, ⛔ not a filing seat's.

⇒ Going ready and into the merge queue (SQUASH). This seat follows it to MERGED.


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review September 17, 2026 21:35
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit 627382b Sep 17, 2026
44 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-18545-formula-can-receiver branch September 17, 2026 21:53
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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants