Repository navigation
perf(security): batch explain runs its object-level pass once, not once per record - #29208
Conversation
The batch form of security/explain ran a whole singular explanation per record id, the object-level pass and the explained context included. The service now takes the batch, settles the caller's admission, the explained context and the object-level pass once, and runs only the record pass per distinct id. The REST door hands the batch over and keeps the per-id path for a service that predates it. Claude-Session: https://claude.ai/code/session_01ADHkmNqE95va9aNi7nYajP Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmNqE95va9aNi7nYajP Co-authored-by: Claude <noreply@anthropic.com>
… once Claude-Session: https://claude.ai/code/session_01ADHkmNqE95va9aNi7nYajP Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmNqE95va9aNi7nYajP Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmNqE95va9aNi7nYajP Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmNqE95va9aNi7nYajP Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 30 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 — 31 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 b96d5fee3c995846c86eb14ec4abe3d4579d88b1 && git checkout b96d5fee3c995846c86eb14ec4abe3d4579d88b1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dedc350519f22c7322e8f618a7a18c1dba6ff0f4 fc99ac4f0bef28e0bbc29e536a4d1b9162e73f51 && git checkout -B drift-repro dedc350519f22c7322e8f618a7a18c1dba6ff0f4 && git merge --no-ff fc99ac4f0bef28e0bbc29e536a4d1b9162e73f51
node scripts/docs-audit/affected-docs.mjs --json dedc350519f22c7322e8f618a7a18c1dba6ff0f4
|
|
domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt 席位核对ACCEPT · The maintainer's words for this seat, verbatim: 「接手所有开发,包括跟进之前未完成的开发落地。」
Generated by Claude Code |
|
domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt Landing record · PR #29208
Release: ships Generated by Claude Code |
Requested by Steve · project thread
Fixes #29112
Clause-②: no
Metadata-protocol: none
维护者速读
security/explain带recordIds)不再每条记录跑一遍完整解释;调用者校验、被解释用户的上下文和对象级九层只算一次,每条记录只跑记录级判断。packages/plugins/plugin-security/src/explain-engine.ts(explainRecordsForAsker与拆出的对象级/记录级两段)、security-plugin-explain-access.ts(depsFor)、packages/rest/src/security-routes.ts(把整批交给服务,旧服务退回逐条)。pnpm --filter @objectstack/plugin-security exec vitest run src/explain-batch-parity.test.ts,预期 44 例全绿。Server-Timing runtime ≤ 500 ms需发版后由 cloud 侧复测(见 Acceptance notes)。What changes
explain-engine.ts).explainOnceis split, with no change in what it computes, into the object-level pass (explainObjectPass: the nine layers and the facts the record pass reads), the record pass (explainRecordOnPass) and the decision builder (decisionOf). A singular explanation runs them in the same order on the same values as before. A new module export,explainRecordsForAsker, runs the object-level pass once and then the record pass per distinct id, each over its own copy of the pass's layers and with the per-explanation read memosexplainAccessForAskersets up for one explanation set up afresh. The object-level answer is taken before any record pass runs. The sharing service's read filter (a question about the principal and the object, never a record) is asked once per batch.security-plugin-explain-access.ts). The deps bag is now built per record (depsFor(rid)); only its record-grained members readrid.request.recordIdsis honoured: the caller's admission and the explained context are settled once and the engine's batch entry answers.recordIdbesiderecordIdsis refused, as the wire schema refuses it. The publishedSecurityPlugin#explainAccessForCallersignature is unchanged.security-routes.ts). The batch is handed to the service in one call. An answer withrecordsof the batch's length is relayed, a position with no verdict failing closed as before. A service that predates this answers the object-level decision with norecords, and the door then asks each id singularly, as it always did.Why
The batch form ran
svc.explainonce per id, serially, and each call redid the explained principal's context (its grant rows read again), its person row and the whole object-level pass. On the rig below that was 10 queries per id; it is now about 4.5, all of them the record's own questions.Tests
packages/plugins/plugin-security/src/explain-batch-parity.test.ts(44 cases, realSecurityPlugin,SharingServiceand sharing middleware over ObjectQL and better-sqlite3, asked through the registeredsecurityservice):privateobject behind sharing with an own-row, a shared row, an unshared row, an RLS-locked row, a missing id and duplicates; apublic_read_writeobject with an app-authored read policy hiding a row). The member's grant carries awhen-conditioned field entry and aunitread depth with no hierarchy resolver mounted. Each case asserts the batch minusrecordsequals the object-level explanation, andrecordsequals the singularrecordverdicts in order.recordIdbesiderecordIdsis refused.packages/rest/src/security-routes.test.ts: three new cases (a batch-capable service is called once with the whole batch and its records are relayed; batch-capable AGREEMENT through the real handler; a verdict-less position fails closed).security-routes.test.ts, "answers records[i] for recordIds[i]": the first service call now carriesrecordIds(the fallback calls still carry none). Was: no call carriesrecordIds.security-explain-envelope.test.ts, the "batch — POST recordIds, refused by the object-level pass" row: the request the service receives now carriesrecordIds.Acceptance notes
update): a singular explanation costs about 5 ms CPU; the 50-id batch costs about 147 ms CPU where the per-id loop cost about 284 ms (243 ms onmain's own sources). The per-id margin is about 2.9 ms (card bound: 10 ms, met). The "3× a single" arm is NOT met: about 4.5 per-record queries per id remain (the caller's by-id read through the read middleware, the system re-read of the row, the sharing gate and the share listing), and they are each record's own questions.record-page-routes.tsis unchanged: its update / delete / transfer verdicts are three different operations, so they share no object-level pass, and this change gives them nothing to share.ExplainAccessRequest(packages/spec/src/contracts/security-service.ts) does not declarerecordIds, while the wire schema (ExplainRequestSchema.recordIds) and the decision (ExplainDecisionSchema.records) do. This PR touches nopackages/specfile; declaring it there is a follow-up for the maintainer to decide.__rlsMembershipStaged, set before its resolution awaits), so concurrent record passes on one context could read a half-staged membership. The record passes stay serial, in first-occurrence order, as the door asked them.Verification
Head
fc99ac4f0bef28e0bbc29e536a4d1b9162e73f51. Taken on the final head afterorigin/mainwas merged in (merge base4550091a1).Red first on main's sources
explain-batch-parity.test.tswithexplain-engine.tsandsecurity-plugin-explain-access.tsrestored fromfea2dbb7:Tests 42 failed | 2 passed (44)(the service ignoredrecordIds:expected undefined to deeply equal [ ... ]). The 2 that pass there are the read-count pins, vacuously: on main the service never sees a batch, so their red evidence is the ablation below.security-routes.tsrestored fromfea2dbb7:Tests 4 failed | 48 passed (52).Ablations (scripts/ablation-replace.mjs, restored blob == HEAD each time)
explainRecordsForAsker. Read-count pins red, 2/2:findOne sys_userexpected 1 received 6 (the member), expected 2 received 6 (the administrator).if (false as boolean)). Red, 2/25: "hands a batch-capable service the whole batch ONCE", "a position ... fails CLOSED".Benchmark readings (shared box, 50 rows, CPU via process.cpuUsage, medians)
A throwaway script, not committed.
Package tests and typecheck
pnpm --filter @objectstack/plugin-security test: Test Files 239 passed, Tests 5171 passed / 45 skipped.pnpm --filter @objectstack/rest test: Test Files 367 passed, Tests 6966 passed / 332 skipped.pnpm --filter @objectstack/plugin-security typecheck: green.pnpm --filter @objectstack/rest typecheck: green after a test-layer type fix (fc99ac4f).--no-inline-config) on the six touched source and test files: exit 0.Derived gate list (dispatch-gates --commands)
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackonfc99ac4f: 65 commands. Run reconciliation (--ran): 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.node scripts/check-plugin-teardown-shape.mjs --self-test: exit 3 (its pinned fixture commit was outside the shallow clone); after fetching that commit, exit 0, "48 cases pass".pnpm check:i18n: exit 3 (PREREQUISITE NOT MET, CLI not built); after the closure build it names, exit 0, "OK (10 package(s) — all bundles in sync, no undeclared authoring keys)".pnpm check:dual-build-cjs-loads: exit 3, PREREQUISITE NOT MET (needs every package's dist/, a whole-workspace build). NOT MEASURED locally; CI's Lint and Repo Gates runs it on this PR.Human Minimum
packages/specholds no runtime logic.pnpm lintpasses (touched files locally; repo-wide in CI).main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed. (Not touched.)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code