Skip to content

feat(spec): ISecurityService gains the effective-object-permission reader - #19622

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-19578-isecurityservice-effective-object-permission-reader
Sep 21, 2026
Merged

os-steve merged 2 commits into
mainfrom
claude/issue-19578-isecurityservice-effective-object-permission-reader

Conversation

@os-steve

@os-steve os-steve commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #19578

Clause-②: yes (widening)

Executes the maintainer's ruling (decision batch 156, item 5, letter A, 2026-09-18): 「ISecurityService gains an effective-object-permission reader, threaded through the engine so evaluateOptionVisibility populates EvalContext.permissions; a census of every current_user-bound predicate evaluation site comes first」.

This PR is the packages/spec half only — the declaration. The engine-side threading, evaluateOptionVisibility and the packages/objectql / plugin-security wiring stay on the engine card (#18783), which is pm:blocked on 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 are EffectiveObjectPermission, declared in packages/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 the objects map that GetEffectivePermissionsResponseSchema already serves, and @objectstack/formula's EvalPermissions — the type of EvalContext.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_decision if 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:

  • Return type — fixed by EvalPermissions in packages/formula/src/types.ts:40, which is the declared type of the ruling's own target slot.
  • Whole map vs. a per-object reader — fixed by the permission predicate's own documented semantics: "an object the effective map does not mention is an object with no grant, and answers 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.
  • Failure stance — fixed by the same door's "Absent is not empty" rule (packages/formula/src/types.ts): with no map at all can() throws; an EMPTY map is a real answer evaluating to false. 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

resolvePermissionSetsForContext deliberately 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/permissions serves it, and the permission predicate reads it — and the second consumer cannot tell a wrong map from a right one. toEvalPermissions already 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.permissions carries permission-set NAMES, so this is a new reader and not a re-projection of something already threaded; and the hard serial pair in rule-validator.ts is not this diff's surface — this diff touches no file in packages/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 no test-typecheck-debt.json entry, so its budget is zero: the file’s seven @ts-expect-error pins compile as errors-that-must-exist, and an unused one would red this. ⚠️ This diff adds one of those seven — six are pre-existing at the base. An earlier revision of this sentence said 「three」, which was wrong; the mechanism is unchanged, because a zero debt budget makes every pin in the file self-falsifying.
  • 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 test and pnpm --filter @objectstack/spec check:generated — results in the report comment on the card.
  • The gate families were derived from the diff by 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 getEffectiveObjectPermissions carrying an incompatible signature. git grep for that identifier across this repo and the pinned ../objectui checkout 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/permissions in packages/plugins/plugin-hono-server/src/current-user-endpoints.ts builds 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.

⚠️ The pin-count sentence above was corrected by the dispatching seat after the at-tier contract review measured it, under the standing rule that the dev writes this body once at PR-open and the seat writes any later change. Re-verified first-hand before writing, not adopted: the diff adds 1 @ts-expect-error line, 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

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 2 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/index.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/kernel/runtime-services/index.mdx (via ISecurityService (symbol, a top-level interface))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via ISecurityService (symbol, a top-level interface))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 — 136 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 9cc5010c708c55d9a0d0c58d955e4b0293cef904 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6803dec38c237750374bc19e8181f2b6b8549f41 — the merge of head 52f0330d9781b1ef3e1dbdadfc78a68f47f35755 into base 9cc5010c708c55d9a0d0c58d955e4b0293cef904, 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 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

⚠️ 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 9cc5010c708c55d9a0d0c58d955e4b0293cef904 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Contract review

read 2026-09-21T20:42Z

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 52f0330d9781b1ef3e1dbdadfc78a68f47f35755

① Derived judgments

Declared member, as it stands at this head:

getEffectiveObjectPermissions?(context?: SecurityContext): Promise<Readonly<Record<string, EffectiveObjectPermission>>>

Is the declared shape RIGHT — the three anchors, each read from the tree rather than taken from the body.

  • EvalContext.permissions. packages/formula/src/types.ts:120 declares it EvalPermissions; :40 declares EvalPermissions = Readonly<Record<string, EffectiveObjectPermission>>, over EffectiveObjectPermission imported from @objectstack/spec/security (:16). The declared return is that construction verbatim, over the same element type. This is the ruling's own named target slot, so the engine half hands the answer straight in with no reshaping.
  • GetEffectivePermissionsResponse.objects. packages/spec/src/api/protocol.zod.ts:2569 — z.record(z.string(), EffectiveObjectPermissionSchema). A Record is assignable into a Readonly<Record> return, so the endpoint's map flows through unchanged. readonly on a return position constrains the caller, not the implementer, so it is the safe direction and not a narrowing.
  • EffectiveObjectPermissionSchema. packages/spec/src/security/permission.zod.ts:595, type at :617 (z.input<…>). Confirmed not declared in protocol.zod.ts, which only imports it at :28 — the seat's corrected reading is the right one.

Parameter, optionality, and the absent context. context?: SecurityContext is character-for-character the sibling spelling at security-service.ts:397 (resolvePermissionSetsForContext?(context?: SecurityContext)), and SecurityContext = Partial<ExecutionContext> is all-optional by construction with the type's own docblock covering partial callers. An absent context therefore needs no separate clause on this member, and the answer it yields — an empty map — is already a defined real answer under the member's own doc rather than an undefined state. No gap, and the optional ? is what a widening requires.

The three shape questions really are closed by published contracts; I read the closures, not the argument for them. packages/formula/src/stdlib.ts:160-162 states that "an object the effective map does not mention is an object with no grant, and answers false" — so a caller may not narrow the map, and the reader correctly takes no object parameter. stdlib.ts:198-207 is the failure stance in the consumer's own runtime code: with binding.permissions === undefined, can() throws, and its message names the shape outright — "the objects map of the published /auth/me/permissions response, object name -> EffectiveObjectPermission". No map is a loud throw, an empty map is a quiet real answer, so a reader that degraded to {} would publish a denial of everything as measured fact. The declared contract matches on all three.

Was the published type reused rather than duplicated? Yes. EffectiveObjectPermission is imported from ../security/permission.zod.js; no parallel entry shape is declared anywhere in the diff. The only thing restated is the Readonly<Record<string, …>> wrapper, and it must be restated: packages/spec/package.json carries no @objectstack/formula dependency in dependencies, devDependencies or peerDependencies, while packages/formula/package.json depends on @objectstack/spec (workspace:*). Importing EvalPermissions would invert that direction, so restating the wrapper over the imported element type is the only available spelling. Not a duplicate shape.

The census question — done is the right call, and needs_decision would have been wrong. The ground is the ruling text, not the author's argument for it. Comment 5725678115 does not merely order a census. Under Interface it directs: 「the claimant files the spec sub-card for the member (Clause-②: yes, the spec lane's at-tier review, changeset @objectstack/spec minor) and this card carries the wiring (packages/objectql + packages/plugins/plugin-security), Blocked-by: that sub-card」. The ruling pre-specified this PR's clause, its review tier, its semver level and its shape (「the caller's effective object permissions in the /auth/me/permissions shape (the map can already reads)」). The Census first bullet defines its own rows as "populates permissions, or is out of scope with a reason" — rows about evaluation SITES, carried by the wiring card. The board agrees: #19578 is titled as the packages/spec half and #18783 is pm:blocked / domain:engine, so the split is recorded rather than asserted. A census over sites cannot move a return type pinned at both ends by already-published contracts, and the body declares 「⛔ No census was run here」 outright rather than implying one. The contract was not declared prematurely.

Do the pins pin the contract, or merely the syntax? Mostly the contract.

  • The @ts-expect-error on the unguarded call pins OPTIONALITY, and it is self-falsifying: security-service.test.ts has no entry in packages/spec/test-typecheck-debt.json (53 entries, none matching; control — src/ai/conversation.test.ts is one of them), so its budget is zero and an unused directive reds check:test-typecheck.
  • Optionality is closed from the other direction too, by the file's pre-existing RequiredMember / UnlistedRequiredMember / satisfies readonly RequiredMember[] machinery: making the member required would make UnlistedRequiredMember non-empty and stop the file compiling.
  • The RETURN TYPE is genuinely pinned, because makeService(overrides: Partial<ISecurityService>) type-checks every override literal against the declared signature. apiOperations: ['get', 'list'] is the real discriminator: apiOperations exists only on the response-side EffectiveObjectPermissionSchema and deliberately not on the authoring ObjectPermissionSchema, and both values are members of API_OPERATION_ORDER (packages/spec/src/data/object.zod.ts:65). Swapping the element type to the authoring one would fail excess-property checking, so the test pins the effective-vs-authored distinction it claims to pin.

⚠️ Two nits, neither a FAIL ground. (a) The body states "the three @ts-expect-error pins"; this diff adds one. The file carries 7 at this head, 6 of them pre-existing. The mechanism the sentence relies on is sound, but the count is wrong and a reader checking the claim will not find three. (b) Two of the five new assertions measure language semantics rather than the contract: expect(withoutIt.getEffectiveObjectPermissions?.({…})).toBeUndefined() holds by optional chaining alone, and expect(map && 'lead' in map).toBe(false) / expect(map?.lead).toBeUndefined() assert about a literal the test itself wrote. The normative "absent entry = no grant" rule is enforced in @objectstack/formula, which packages/spec cannot reach from here — so those lines document rather than pin.

api-surface / json-schema untouched is CORRECT — verified with a radius and a control, not accepted. packages/spec/api-surface/contracts.json records ISecurityService (interface): an export NAME plus kind. getReadableFields, a pre-existing MEMBER of that same interface, appears in no api-surface shard (exit 1), while ISecurityService appears in the same radius (exit 0) — so interface members are not recorded, and adding one moves nothing. api-surface-signatures.json is scoped to the 27 defineX factories (read in full); ISecurityService is not one, and build-api-surface.ts's own SCOPE docblock confirms both artifacts describe the export surface only. In packages/spec/json-schema/, ISecurityService is absent (exit 1) while EffectiveObjectPermission is present (exit 0 — json-schema/security/EffectiveObjectPermission.json and objectstack.json): the radius is zod schemas, and a TypeScript interface member falls outside it. No regeneration was owed, and no baseline update is missing.

② Semver level

The changeset grades '@objectstack/spec': minor, and @objectstack/spec is the only package this diff moves. Against judgeLevel (scripts/check-changeset-no-major.mjs:1618) that yields offenders (moved packages graded patch) empty and raised (moved packages graded minor or above) = @objectstack/spec minor, so refusable = offenders.length > 0 && raised.length === 0 is false: the level axis discharges rather than reaching the enforce verdict. minor is correct and required for a widening, and no other moved package is left at patch because there is no other moved package. Check Changeset is green on both of its runs.

③ Boundary flags

Clause-②: yes (widening) is correct, judged from the diff and the card. The source file's entire change is one optional member added to ISecurityService, plus the single deleted line — the existing type-only import, rewritten to add EffectiveObjectPermission to its brace list. Nothing renamed, nothing narrowed, nothing removed; a security service that omits the member still satisfies the contract. Declared in both the PR body and the changeset.

Scope discipline holds. 3 files, +216/−1: the changeset, packages/spec/src/contracts/security-service.ts, packages/spec/src/contracts/security-service.test.ts. Nothing under packages/objectql, nothing in plugin-security, nothing touching evaluateOptionVisibility — the engine half stays with #18783, which is pm:blocked on this card.

⚠️ CI state at review time: NOT CONVERGED — and this verdict does not underwrite the part that had not landed. At the reading stamped above, the head carried 37 check runs: 27 success, 6 skipped, 4 still in_progress — Test Core (1/6), Test Core (5/6), Test Core (6/6) and Type Check · workspace. Zero were red, failed, cancelled or timed out, and none turned red while I watched the set converge from 14 outstanding down to 4. Everything this review's own findings rest on had already landed green: Lint & Repo Gates (which settles the line-length question the new import line raises), Build Core, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Check Changeset on both runs, Governed Surface Queue Guard, Dogfood Regression Gate on all three shards, Temporal Conformance (live PG + MySQL), and Test Core shards 2/6, 3/6 and 4/6.

⛔ But Test Core is one of the seven REQUIRED_CONTEXTS (scripts/check-required-contexts.mjs), so three of its six shards plus the workspace typecheck were still outstanding when this record was written. The verdict below is the contract verdict — the shape, the reuse, the census call, the clause, the level, the scope, the pins and the baselines — and it does not attest to those four runs. Confirm they are green before acting on it; anything red there is a finding this record has not made.

Not acted on by this review. needs:contract-review is left hanging as dispatched; no label write, push, approval, draft flip, enqueue or merge was performed. The docs-drift comment is advisory and its gate is green — neither page it lists enumerates ISecurityService members (content/docs/kernel/contracts/index.mdx:42 is a one-line prose row), so this member leaves no doc obligation behind. #18545, named on the card's Refs line, answers 404; per dispatch that is a suspended account's issue and nothing was inferred from its absence.

Implemented-by: claude/issue-19578-isecurityservice-effective-object-permission-reader
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: PASS


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 21, 2026 20:49
@os-steve
os-steve added this pull request to the merge queue Sep 21, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 35653654976 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (5/6) — 失败步骤: Publish this shard's attestation(日志不可读,点进 job 看)

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️ 本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 0 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Copy link
Copy Markdown
Collaborator Author

Merge-queue dequeue triaged — the tests PASSED; the artifact upload got a 403, 2026-09-21T21:50Z

The triage bot said the log excerpt was unreadable and asked for a manual read. Here it is, from the failing
job's own log (Test Core (5/6), job 106511930225, queue run 35653654976, queue head 74456e8b5c).

The failing step is not a test — and the log says the tests passed

check-test-completeness: OK (11 of 11 scheduled package(s) reported, 0 had nothing to run,
                             0 never reached; 8884 test(s) declared and all accounted for).
…
Attested: test-5-of-6 ran to completion with every step green (run 35653654976, attempt 1).
…
##[error]Failed to FinalizeArtifact: Received non-retryable error:
         Failed request: (403) Forbidden: Error from intermediary with HTTP status code 403 "Forbidden"

⇒ the shard ran its full 8884 declared tests, all accounted for, and attested itself green. Both
actions/upload-artifact@v7 finalizations then failed — the timings artifact and the shard attestation —
each with the same (403) Forbidden … from intermediary. The job failed on the upload, and Test Core
then failed its Verify test shard results step because the attestation artifact it reads was never
finalized.

⛔ There is no AssertionError, no Test timed out, no Hook timed out anywhere in the log — the two
signatures the triage note says separate a real regression from load. The failure names a service, not a
test.

Why this is ⛔ not this PR's

reading value
this PR's diff 3 files: a changeset, an interface declaration, its sibling test — ⛔ nothing touching CI, artifacts or uploads
this PR's own head CI, 52f0330d9781b1ef3e1dbdadfc78a68f47f35755 35 latest-per-name, 30 success, 5 skipped, 0 red — including Test Core (5/6), the same shard, whose attestation upload succeeded there
⭐ the nine merge_group CI runs before this one all success — 9cc5010c70, 5c5b67fc41, 236cec19a5, f77b806565, 0e658fbe93, a60e04d7d4, 1f69917c5c, 8dba7aac31, b49728f700
the same signature on any other recent failed run none — the other recent failures are a PR Automation red on a different PR and two Lint & Type Check reds on other heads, none carrying an artifact 403
the bot's own history line 「本 PR 过去 24h 无队列失败记录(首次)」 · 「过去 24h 队列共有 0 个失败构建(不含本次)」

⇒ a one-off artifact-service refusal. ⛔ Not a flake diagnosis offered in place of a root cause: the root
cause is named, quoted from the log, and is an upload returning 403.

What happens next, and the budget it spends

Re-arming once, which is the only retry available for a dequeue (the queue run is gone; re-arming builds a
fresh one). That is the single re-run this PR gets. ⚠️ If the next queue build fails the same way, it is ⛔ not
re-armed again — it becomes a report against the artifact service with two identical readings, not a third
rebuild of everything behind it in the queue.

⛔ Nothing about the PR changes: no commit, no body edit, no label. The head stays
52f0330d9781b1ef3e1dbdadfc78a68f47f35755.


Generated by Claude Code

@os-steve
os-steve added this pull request to the merge queue Sep 21, 2026
Merged via the queue into main with commit 1c16889 Sep 21, 2026
51 checks passed
@os-steve
os-steve deleted the claude/issue-19578-isecurityservice-effective-object-permission-reader branch September 21, 2026 22:11
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

2 participants