Repository navigation
Conversation
…tyService
`ISecurityService.getEffectiveObjectPermissions(context?)` — the server-resolved
effective object-permission map (object name -> EffectiveObjectPermission), the
same answer the published /auth/me/permissions response carries in its `objects`
slot, single-sourced so a second consumer cannot hand-build one.
Optional, whole-map (no object parameter — an omitted entry reads as a denial),
throws on resolution failure and never degrades to `{}` (an empty map is a real
answer). Spec half only: no engine threading rides with it.
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
…mission reader Clause-②: yes (widening) — one optional member on a published interface. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 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 6803dec38c237750374bc19e8181f2b6b8549f41 && git checkout 6803dec38c237750374bc19e8181f2b6b8549f41
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9cc5010c708c55d9a0d0c58d955e4b0293cef904 52f0330d9781b1ef3e1dbdadfc78a68f47f35755 && git checkout -B drift-repro 9cc5010c708c55d9a0d0c58d955e4b0293cef904 && git merge --no-ff 52f0330d9781b1ef3e1dbdadfc78a68f47f35755
node scripts/docs-audit/affected-docs.mjs --json 9cc5010c708c55d9a0d0c58d955e4b0293cef904
|
Contract reviewread 2026-09-21T20:42Z Served-tier: ① Derived judgmentsDeclared member, as it stands at this head:
Is the declared shape RIGHT — the three anchors, each read from the tree rather than taken from the body.
Parameter, optionality, and the absent context. The three shape questions really are closed by published contracts; I read the closures, not the argument for them. Was the published type reused rather than duplicated? Yes. The census question — Do the pins pin the contract, or merely the syntax? Mostly the contract.
② Semver levelThe changeset grades ③ Boundary flags
Scope discipline holds. 3 files,
⛔ But Not acted on by this review. Implemented-by: VERDICT: PASS Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 35653654976 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Merge-queue dequeue triaged — the tests PASSED; the artifact upload got a 403, 2026-09-21T21:50ZThe triage bot said the log excerpt was unreadable and asked for a manual read. Here it is, from the failing The failing step is not a test — and the log says the tests passed⇒ the shard ran its full 8884 declared tests, all accounted for, and attested itself green. Both ⛔ There is no Why this is ⛔ not this PR's
⇒ a one-off artifact-service refusal. ⛔ Not a flake diagnosis offered in place of a root cause: the root What happens next, and the budget it spendsRe-arming once, which is the only retry available for a dequeue (the queue run is gone; re-arming builds a ⛔ Nothing about the PR changes: no commit, no body edit, no label. The head stays Generated by Claude Code |
…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 #19578
Clause-②: yes (widening)
Executes the maintainer's ruling (decision batch 156, item 5, letter A, 2026-09-18): 「
ISecurityServicegains an effective-object-permission reader, threaded through the engine soevaluateOptionVisibilitypopulatesEvalContext.permissions; a census of everycurrent_user-bound predicate evaluation site comes first」.This PR is the
packages/spechalf only — the declaration. The engine-side threading,evaluateOptionVisibilityand thepackages/objectql/plugin-securitywiring stay on the engine card (#18783), which ispm:blockedon this one, together with the census the ruling orders first.What lands
ISecurityService.getEffectiveObjectPermissions?(context?), returning a promise of a readonly Record keyed by object name whose values areEffectiveObjectPermission, declared inpackages/spec/src/contracts/security-service.ts, plus five contract pins in its test file. One optional member on a published interface; nothing renamed, narrowed or removed.The return type is the published one, not a new parallel shape.
EffectiveObjectPermission(@objectstack/spec/security) is the entry type of theobjectsmap thatGetEffectivePermissionsResponseSchemaalready serves, and@objectstack/formula'sEvalPermissions— the type ofEvalContext.permissions, the slot the ruling names — is that same readonly Record, verbatim. So the engine half hands the reader's answer straight into that slot with no reshaping, which is the whole point of single-sourcing it.Why the shape is settled WITHOUT the census
The claim comment flagged that this card must return
needs_decisionif the reader's shape cannot be settled before the census. It can, and here is the measurement rather than the assertion — three shape questions, each closed by a published contract:EvalPermissionsinpackages/formula/src/types.ts:40, which is the declared type of the ruling's own target slot.false" (packages/formula/src/stdlib.ts). A narrowed map is therefore indistinguishable from a measured denial, and the objects a predicate names live in the predicate's source, not at the site assembling the context. The map cannot be narrowed by the caller, so the reader takes no object parameter.packages/formula/src/types.ts): with no map at allcan()throws; an EMPTY map is a real answer evaluating tofalse. So the reader must throw rather than degrade to an empty map — degrading would publish "this subject holds nothing" as a measured fact — and an absent METHOD must leave the caller passing no permission data at all, never an empty map.What the census decides is which sites get threaded and what context each holds. Neither moves any of the three above: the context parameter is already a Partial of
ExecutionContext, and narrowing for cost is refused by the second bullet. ⛔ No census was run here.Why a reader on the service, not a merge in the engine
resolvePermissionSetsForContextdeliberately leaves the merge to the caller, "because two consumers legitimately project different subsets of the same sets". This map is the projection two consumers need to be identical —/auth/me/permissionsserves it, and the permission predicate reads it — and the second consumer cannot tell a wrong map from a right one.toEvalPermissionsalready names the hazard at its own door: a hand-built permission map "has no shape of its own to be wrong against: it parses,can()answers from it, and the answer is a confident silent denial". One producer removes the second copy before it is written.The two constraints the card carried over hold as read:
ExecutionContext.permissionscarries permission-set NAMES, so this is a new reader and not a re-projection of something already threaded; and the hard serial pair inrule-validator.tsis not this diff's surface — this diff touches no file inpackages/objectql.Verification
Commands, with each exit code captured before any pipe and the gate's own verdict line read rather than a bare status:
pnpm --filter @objectstack/spec build— exit 0 (check-dts-emitted: 34/34 declared declaration file(s) present).pnpm --filter @objectstack/spec typecheck— exit 0 (tsc --noEmit,check:scripts-typecheck,check:test-typecheck). This test file carries notest-typecheck-debt.jsonentry, so its budget is zero: the file’s seven@ts-expect-errorpins compile as errors-that-must-exist, and an unused one would red this.pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/contracts/security-service.test.ts— exit 0, 19 passed (14 before this diff).pnpm --filter @objectstack/spec testandpnpm --filter @objectstack/spec check:generated— results in the report comment on the card.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, ⛔ not from a hand-rolled list. Per-family verdicts, and everything that derivation itself marks NOT MEASURED, are in the report.Consumer sweep, narrowed and declared. A new OPTIONAL member can break a downstream implementor in exactly one way: a pre-existing
getEffectiveObjectPermissionscarrying an incompatible signature.git grepfor that identifier across this repo and the pinned../objectuicheckout returns this declaration and its pins and nothing else, so there is no implementor to conflict with — which is why the downstream consumer closure was not rebuilt. The counts and the lit control are in the report.Acceptance notes
/auth/me/permissionsinpackages/plugins/plugin-hono-server/src/current-user-endpoints.tsbuilds this exact map inline today (the most-permissive merge,seedSuperUserRestrictedObjects,foldWildcardSuperUser,clampManagedObjectWrites,annotateEffectiveApiOperations). Moving it behind this reader is the natural second consumer and would make the endpoint and the engine provably one answer — but it is runtime work in another package, outside this card and outside the engine card as split. Noted, not filed: no carrier is in flight for that file.@ts-expect-errorline, the base file carries 6, the head carries 7. Nothing else in this body was touched.Authored in Claude Code session
session_01AmH9bKvGoLjiY86Q4Z3og2(https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2).Generated by Claude Code