Repository navigation
feat(formula): register can receiver-only and answer it from permission data in EvalContext - #18781
Conversation
…ontext Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude <noreply@anthropic.com>
…abulary Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude <noreply@anthropic.com>
…rmula-can-receiver
Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 137 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 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
|
|
Pre-landing checks, all three re-taken at this stroke — ⛔ none carried over.
Governed-surface predicate, derived by the script from this PR's own file list: ⏹️ The two rulings this round produced both have carriers before the PR lands, ⛔ not after: #18783 (the server side does not populate ⇒ Going ready and into the merge queue (SQUASH). This seat follows it to MERGED. Generated by Claude Code |
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
canalone would make the publish gate passcurrent_user.can(object, verb)with zero errors while nothing could evaluate it — the production evaluator receives{ now, timezone, user, org, record }andEvalUsercarries no permissions. The author's predicate publishes green and faults on every screen at runtime (the #13594 regression, recorded atpackages/lint/src/validate-visibility-predicates.ts). So the acceptance property is a pair, and the test suite asserts both halves: acanpredicate given permission data evaluates, and one that is not fails loudly at evaluation.One trunk, landing together
canregistered receiver-only in@objectstack/formula(dyn.can(dyn, dyn): bool).current_user.can(...)resolves; a barecan(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 treatmentsplitalready gets.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 ontocanApprove, plus a new case pinning whatcananswers today.packages/formula/src/validate.test.ts(the "invented method on the canonical user root" case) — re-pointed ontocanApprove, plus an ACCEPTS case forcurrent_user.can(record, "read").packages/formula/src/validate.test.ts(thecan→mindid-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 ofcan, which is a receiver-only name.packages/lint/src/runtime-gate.test.tsandpackages/lint/src/validate-visibility-predicates.test.ts— same treatment, refusal case plus acceptance case.packages/lint/src/validate-visibility-predicates.tskeeps the lint:validate-visibility-predicatespasses unknown CEL functions clean (thevalidateExpressionpremise 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:289as measured not to red. Confirmed on this tree —nearestName('can', CEL_STDLIB_FUNCTIONS)still answers'min', becausecanis receiver-only and the catalog advertises bare-callables only (cel-stdlib-drift.test.tscase D pins that it must stay out). What DID red is line 278, three cases up, in a differentitblock.A real producer of permission data into
EvalContext— see below.The three ruled sub-questions, as implemented
① Pure data map.
EvalContext.permissionsis a readonly record of object name toEffectiveObjectPermission— theobjectsmap of the published/auth/me/permissionsresponse, 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 andstdlib.ts's purity invariant. The map is pinned when the environment is built, exactly likenow, 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. Nevertrue, never a silentfalse. 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_VERBSis DERIVED fromOBJECT_PERMISSION_KEY_ALIASES' bare verbs — every alias key landing on anallow*bit that is not acan-prefixed spelling of another — so the two can never disagree, and the derived result is pinned exactly inpermission.test.ts(a derivation with no pin absorbs an alias-table edit silently).import→allowCreateis 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/purgeare 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/permissionsis the producer, and it already ships. What this PR adds is the path from that response intoEvalContextand the loud refusal when nothing walked it:toEvalPermissions(response.objects)parses each entry with the publishedEffectiveObjectPermissionSchemaand refuses a payload that is not that shape, andcelEngine.evaluatebinds the result. Structurally this is the opposite of #18318 — the field is bound, and any caller that passes it gets a workingcanwith no further wiring in any package.Declared gap, deliberately not closed here. No in-repo evaluation call site populates
permissionsyet. The candidates all sit outside the file surface the ruling declared at claim time:packages/objectql'sevaluateOptionVisibilityand its formula-field evaluation, the seed loader inpackages/metadata-protocol, and objectui's renderer (objectui#4421, which the ruling keepspm:blockedon this card). The first of those additionally needs an effective-permission source the ObjectQL engine does not hold —ExecutionContext.permissionsis permission-set NAMES, not object bits — so wiring it would pull inpackages/plugins/plugin-securityand 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 barepermission[bit] === trueanswersfalsefor a caller the server lets through — hiding an action from the one administrator who holds the power to use it, which is thedeclared ≠ enforceddefect pointed the dangerous way round.objectPermissionGrantstherefore performs the same foldPermissionEvaluator.checkObjectPermissionperforms: the read bypass onviewAllRecords || modifyAllRecords, the write bypass onmodifyAllRecordsalone (never create),exportasgrant ∧ read. Each cell is pinned.Evidence
All readings taken at
1966c84c, after the final commit and after mergingorigin/main.pnpm --filter @objectstack/formula testpnpm --filter @objectstack/lint testpnpm --filter @objectstack/spec testpnpm --filter @objectstack/{formula,lint,spec} typecheckpnpm --filter @objectstack/spec check:generatedpnpm lint(repo-wide,eslint . --no-inline-config)scripts/pm/dispatch-gates.mjs --ran)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 falseinpackages/formula/src/stdlib.ts. Mutation proved on disk (HEAD blobd7d2c4d8, mutated blob693f59d5). Run: 1 failed / 20 passed, failing exactlythrows when the context carries NO permission data (ruling ②). Restored to blobd7d2c4d8,git diff HEADempty.B. Can the acceptance pins fail? Renamed the registered signature so
canis not registered, rebuilt@objectstack/formulaso the mutation reached the artifact the lint suite consumes, and confirmed withscripts/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 inruntime-gate.test.tsandvalidate-visibility-predicates.test.ts. Restore leg rebuilt and re-ran the pre-flight without--absent(marker back in 2 built files); blob back tod7d2c4d8;git status --porcelainempty across the whole tree.Both ablation scripts carried
trap restore EXIT INT TERMwith 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-changesetwould be wrong here, since both packages publish and both grow.@objectstack/formula: minor. New callable name, newEvalContextfield, 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/mainandcheck:changeset-no-major --base origin/mainboth exit 0 on this branch.Acceptance notes
Observations recorded rather than filed or fixed, per Prime Directive #10:
objectPermissionGrantsin@objectstack/spec/securitynow states the super-user fold thatPermissionEvaluator.checkObjectPermissionin@objectstack/plugin-securityimplements 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'sbuildAccessMatrixfolds 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.packages/lintcarriers". They live inpackages/formula/src;packages/lintcarries two more. All five were found and re-pointed.Generated by Claude Code