Skip to content

perf(plugin-security): batch the identity boot seeds' existence read and skip no-op writes - #11116

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-10946-batch-identity-seed-roundtrips
Aug 22, 2026
Merged

perf(plugin-security): batch the identity boot seeds' existence read and skip no-op writes#11116
os-warren merged 3 commits into
mainfrom
claude/issue-10946-batch-identity-seed-roundtrips

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#10946

Both halves of the card are discharged: the per-item existence read is hoisted out of both loops, and the unconditional UPDATE now fires only when the stored row actually differs.

What was costing what

Every declared permission set and every declared position cost 4 sequential database round trips on every kernel boot — 2 existence SELECTs, 1 UPDATE, 1 SELECT — and the UPDATE fired whether or not anything had changed. bootstrapDeclaredPermissions awaited upsertPackagePermissionSet once per set inside a for loop; the position binder had the same shape. Invisible on a local file database; one sequential HTTP request per leg on a remote libsql/Turso database, i.e. on every hosted environment.

Round trips: measured here, and measured where

⚠️Split deliberately, because only one half of it is mine.

Reproduced in this PR (a COUNT, which needs no hosted rig) — at the OBJECTQL FACADE layer.packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts counts every find / insert / update the seeders issue against a call-counting ObjectQL double. Both columns below were produced by running the same counter twice: once with the four touched source files reverted to origin/main, once at this branch's head. Steady-state rebuild — rows already seeded, nothing to change:

declared itemsfacade calls beforefacade calls after
121
5101
20401
40801

Identical figures for both loops. Measured slope: 2.0 facade calls per declared item, going to 0 — the rebuild cost stops growing with N. The "after" column is what the committed suite asserts. The count is asserted, never the wall time.

⚠️2 is not a contradiction of the card's 4, and I am not restating one as the other. My counter sits at the ObjectQL facade (one find plus one update per item); the card's counter sits at @libsql/client, where each facade call expands into more than one statement — its own histogram shows the per-item UPDATE arriving with a SELECT beside it. The two layers agree at a 2:1 expansion. The driver-level figure is the card's measurement, not mine, and I could not re-take it: the rig is scripts/dev-local/bootstrap-curve.mjs in objectstack-ai/cloud, outside this session's access.

Inherited from the card, NOT reproduced. The latency figure — the whole bootstrap step growing 171.7 ms per ms of injected RTT, R² = 0.998 — and the driver-level curve fit (slope exactly 4.0000, R² = 1.000000) both come from that hosted rig. Nothing in this PR re-ran it, and nothing here measures wall time.

The card's negative control still holds, by reading rather than by re-measurement. Objects/views/artifact seeds add 0.00 round trips each because schema sync is already batched behind TursoDriver.supports.batchSchemaSync. I confirmed the code path is unchanged by this PR — nothing here touches schema sync — but I did not re-run the 0-to-400-objects leg, so "still 0.00" is inherited too. Identity content was the one content axis not batched; after this PR the capability seeder is the one that remains (filed as #11096).

Half 2 changes WHEN writes happen — what I established first

The card flagged this as the risky half. What I found:

  • ⚠️One real consumer, and it is load-bearing.security-plugin.ts:2840, the ADR-0086 P2 publish materializer, computed applied = r.seeded + r.updated and used it for two things: the publish success/failure report, and whether reconcileAudienceBindingSuggestions runs. It asks "did the record end up matching the published body", which was accidentally the same number as "was a write issued" only because the seeder always wrote. Left alone, a re-publish of an identical body would have reported success: false. Fixed by teaching the consumer the new counter: applied = r.seeded + r.updated + r.unchanged. The three refusal branches (foreign package, env-authored name, no owning package) all leave unchanged at 0, so every case that reported 0 before still reports 0 — behaviour preserved case by case, not approximately.
  • The audit trail does NOT depend on it.plugin-audit's writeAudit diffs before/after and returns before writing any row when the diff is empty (audit-writers.ts:1374-1375), and updated_at / updated_by / created_at / created_by are NOISE_FIELDS dropped from that diff. So a no-op boot UPDATE writes nosys_audit_log or sys_activity row today — nothing is lost by not issuing it. Both objects declare enable.trackHistory: true, which is what made this worth checking.
  • updated_at bumping is not a dependency either. It is displayed in three sys_position list views and is the optimistic-concurrency token (expectedVersion matches the current updated_at). Boot no longer bumping it makes the displayed "Updated" reflect the last real change instead of the last restart, and makes client version tokens survive a restart. Both are improvements, not losses.
  • No change-feed consumer in the tree. The generic data.record.updated realtime event reaches service-knowledge only for explicitly configured knowledge sources (sourcesForObject), and nothing in this repo declares one over sys_permission_set or sys_position. An environment that configured one would today re-upsert an identical document on every boot.

One observation I am NOT deciding, recorded for the maintainer rather than acted on: the seeders write with context: { isSystem: true } but do not set skipAutomations, so an environment-authored automation bound to sys_position / sys_permission_set update fires today once per item per boot with an empty diff. Nothing in the tree declares one, and nothing can depend on it, so this PR does not change that flag.

The four pinned directions

  1. Steady-state rebuild is O(1) round trips — the table above, both loops, count asserted.
  2. ⚠️Drift still reconciles — the skip is on equality, never on "we have seen this name". A row whose stored value differs (package version bump, hand-edit, partially applied write) still gets its UPDATE. For permission sets the comparison is recordDiffersFromBody, the same predicate the ADR-0094 boot reconciler already trusts, over exactly the columns permissionSetRowFields writes.
  3. A genuinely new set/position is still created — the batched read does not turn "absent" into "present"; a first boot still costs one batched read plus one insert per new item.
  4. null is not "not mine" (the approvals: a department approver never resolves when the business unit has organization_id = null (every seeded BU) #3807 class) — the new seed-name-lookup.ts judges the seam on whether the driver returned a result set, never on whether the array came back empty. A thrown read, or a response that is neither an array nor a records-wrapper, is "could not answer"; [] is the answer "none exist", and the first-boot path depends on that answer being trusted.

Point 4 is stricter than the code it replaces, deliberately. The old per-item shape turned a failed read into an insert attempt and leaned on the name unique index to refuse it — a database constraint standing in for a decision the seeder should have been making. Writing the test for this is what exposed it: my first implementation degraded to per-item reads and, against a double with no unique index, happily re-created all four rows. The lookup now reports three outcomes rather than two, and a name whose record cannot be read is declined and counted (unreadable) rather than inserted.

Proof

Ablation A — signature predicted in writing before mutating (remove the equality guard, restore the unconditional UPDATE, keep the batched read):

predictedobserved
steady-state O(1), permission setsREDRED
one batched readGREENGREEN
first boot insert pathGREENGREEN
drift: stored grants differREDRED
all position testsGREENGREEN

Direction fully correct; breadth under-called — I predicted one drift failure and got three more counter assertions red ("hand-edit healed back", "absent into present"), because the ablation moves unchanged everywhere it is asserted. Reported as observed, not as predicted.

The informative asymmetry: under ablation A the pre-existing suite (bootstrap-declared-permissions.test.ts, bootstrap-declared-positions.test.ts) stayed 100% green. The old suite could not see this defect at all.

Ablation B — the load-bearing leg (skip every write: the "beautiful curve, reconciles nothing" implementation): round-trip tests GREEN, drift tests RED, and two pre-existing tests red as well. A vacuous pass is impossible from two independent directions.

Restore, proved and re-run. Every restore was byte-identical by git hash-objectbootstrap-declared-permissions.ts at 3ad017323f162701ed9d4a56baf0b20330db5980 before and after each leg, and the measurement revert restored all four touched files to their committed hashes (3ad01732…, 51ec4c4b…, 5ea4e4e9…, 60f48959…). Each restore leg was re-run to a real verdict: 71 files / 1371 tests passed, three times.

src vs dist, in the falsifiable form.dist/ was built from the implementation commit, then a src-only mutation carrying the marker OS_ABLATION_MARKER_10946 flipped the verdict (4 tests red) while dist/ bytes stayed identical (md5 of all 6 files, diffed clean across both ablations) and the marker had 0 occurrences in every dist file. So the suite resolves through src/. Separately, ablation-dist-preflight.mjs confirms the shipped predicate did reach dist/ (2 built files) for the consumers that read the artifact.

Reverse verification of the cross-package type change.PermissionSeedOutcome gained two required fields; a probe constructing the pre-change literal was rejected with TS2739 ... is missing the following properties from type 'PermissionSeedOutcome': unchanged, unreadable, proving the rebuilt declarations were being read rather than a cache. Probe removed, tree clean.

Downstream sweep, direction stated.pnpm --filter '...@objectstack/plugin-security' — the prefix form, i.e. dependents — is 26 packages. 23 of them ship a typecheck script; all 23 ran (script name echoed 23 times, "Done" 23 times) and all 23 passed with 0 errors. The other 3 (@objectstack/hono, @objectstack/cloud-connection, @objectstack/service-automation) ship no typecheck script at all — verified by reading each package.json, not assumed. Their dependency closure had to be built first with the both-directions filter; the dependents-only filter produced cascading TS2307s from an unbuilt @objectstack/service-datasource, which are not findings.

Zero-hit counter-check, positive control run first. The control-character scan was first run against a file that does contain one (a bell byte, reported at line 2), proving the pattern fires, and only then against all 10 changed files: no hits. check:nul-bytes agrees over 6417 files.

Gates

Union derived on the final commit 4568d6fff, clean tree, with node scripts/pm/dispatch-gates.mjs and no path arguments (the script reads the change set from the merge base itself). Exit codes captured before any pipe. Re-deriving after the second commit added two families the first derivation could not name — check:entry-guard and check:parse-guard, pulled in by the pinned-ledger path — which is exactly why the derivation is re-run on the final committed diff.

Each gate below is quoted by its own verdict line, never a bare shell status:

  • check:changeset-gate-self-tests — 118 + 212 + 116 assertions over real temp git repos
  • check:cross-package-test-inputs / check-cross-package-test-inputs.mjs — "OK: 13 package(s) read outside themselves, all declared"
  • check:entry-guard — "136 scripts/ file(s) ... every entry guard goes through invoked-as.mjs"
  • check:objectui-changeset — pass
  • check:parse-guard — "135 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs"
  • check:slot-lookup — "ratchet holds: 107 unswept site(s) in 25 file(s), none new"
  • check:test-source-alias — "OK — 72 packages with tests scanned"
  • check:type-source-resolution — "OK — 77 packages with a tsconfig.json scanned"
  • check-adr-0087-registration — "this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)"
  • check-changeset-no-major — "This diff introduces no major bump"
  • check-ci-filter-parity — "OK: all 83 declared cross-package glob(s)"
  • check-empty-changeset — "No empty-frontmatter changeset introduced by this diff"
  • check-plugin-teardown-shape — "63 Plugin implementation(s) ... baseline fully burned down"
  • check-affected-docs — pass
  • check:query-options-erasure — "ratchet holds: 67 unswept non-test site(s), none new"
  • check:i18n — "OK (9 package(s) — all bundles in sync)"
  • check:type-check-coverage — "OK — 65/78 workspace packages type-checked"
  • check:nul-bytes — "OK (scanned 6417 text file(s) ... no raw ASCII control bytes)"
  • check:optional-error-sink — "every sink declaring an optional error guarantees a warn channel" (this one can never be named by path derivation; run because the card said so)
  • check:engine-double-contractreddened first, see below — now "OK — 378 pinned, 133 in the DEBT ledger, 2 exempt"
  • check:where-matcherreddened first, see below — now "279 matcher(s) discovered, 279 answer the combinator battery correctly or refuse it loudly (168 refuse)"
  • check:type-check-debt --re-measurereddened first, see below — now "OK — 33 ledger entr(ies) re-measured in 338.2s, 1908 raw tsc error(s) total, none above its recorded number". Run against a fully built workspace closure (70/70 tasks), so this is a measurement, not a refusal.

The three that reddened, and how each was repaired

All three were caused by the new counting double, and all three were repaired in the code:

  1. check:engine-double-contract — the double's update() did not route through assertEngineUpdateDispatch. Repaired by routing it through the real dispatch predicate from @objectstack/metadata-core. The follow-up "RETAINED" verdict then asked for the pinned ledger to learn about the new coverage; --write added exactly one row there. ⛔ The shrink-only engine-double-contract.baseline.json is untouched — verified by git status on that file specifically.
  2. check:where-matcher — the double's WHERE matcher read a combinator as a field name. Repaired by making the double refuse the combinators it does not implement, which is the convention most discovered matchers already follow. The baseline was not grown.
  3. check:type-check-debt --re-measure@objectstack/plugin-security TEST_DEBT drifted 11 to 12. The +1 was my own fixture: an inferred grant literal that the upgrade fixture widens (TS2353, allowEdit does not exist in the inferred type). Repaired by declaring the fixture's type; re-measured back to exactly 11. ⛔ The ledger entry was not raised.

Declared narrowing

The dogfood real-engine seeding tests were not run locally.showcase-permission-seeding.dogfood.test.ts and showcase-declarative-rbac-seeding.dogfood.test.ts were attempted and killed mid-boot by this session's 10-minute foreground ceiling, behind a contended shared verify lock — twice the budget went to waiting rather than running. CI runs the dogfood farm exactly once regardless. What stands in for it locally: the membership query shape this PR now issues is already in production on both tables — explain-engine.ts:389 reads sys_position with a name membership predicate, and security-plugin.ts:997 reads sys_permission_set with one — so the engine answering this shape on these two tables is an existing shipped fact, not a new assumption. Stated as a narrowing rather than left as a gap.

Out of scope, filed not repaired here

Neither is touched by this PR. Sibling cards in the same boot-cost campaign: #10945 (engine), #10979 (cli).


Generated by Claude Code

os-warrenand others added 3 commits August 22, 2026 16:49
…and skip no-op writes
Every declared permission set and every declared position cost 4 sequential DB
round trips on every kernel boot, 2 of them an UPDATE that fired when nothing
had changed. Hoist ONE $in existence read out of each loop and write only when
the stored row actually differs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…ispatch predicate
check:engine-double-contract and check:where-matcher both reddened on the new
counting double: its update() did not route through assertEngineUpdateDispatch,
and its WHERE matcher read a combinator as a field name. Fixed in the double —
the shrink-only baseline is untouched; only the pinned (tightening) ledger grew.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
… stays at 11
check:type-check-debt --re-measure went 11 -> 12: the upgrade fixture widens a
grant literal, which the inferred type rejects (TS2353). Fixed by declaring the
fixture's type — the ledger entry is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 22 documentable anchor(s).

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

  • content/docs/api/error-catalog.mdx(via sys_permission_set (literal))
  • content/docs/deployment/tenancy-modes.mdx(via ABSENT (symbol))
  • content/docs/permissions/authorization.mdx(via bootstrapDeclaredPermissions (symbol), bootstrapDeclaredPositions (symbol), upsertPackagePermissionSet (symbol), sys_permission_set (literal), sys_position (literal))
  • content/docs/permissions/delegated-administration.mdx(via sys_permission_set (literal), sys_position (literal))
  • content/docs/permissions/permission-sets.mdx(via sys_permission_set (literal))
  • content/docs/permissions/positions.mdx(via sys_position (literal))

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

  • content/docs/releases/v12.mdx(via bootstrapDeclaredPermissions (symbol), sys_permission_set (literal))
  • content/docs/releases/v13.mdx(via sys_permission_set (literal), sys_position (literal))
  • content/docs/releases/v14.mdx(via sys_position (literal))
  • content/docs/releases/v15.mdx(via sys_permission_set (literal), sys_position (literal))
  • content/docs/releases/v17.mdx(via SYSTEM_CTX (symbol), sys_permission_set (literal), sys_position (literal))

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
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 14 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 13c0b166b8ed498ce7d6db7fd5ae70f1761e848cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 340de11ee4d6bc5633e6e42d862eaee08bbbe723 — the merge of head 4568d6fff2c3cf50d627dc94fa14fc4afe8b1822 into base 13c0b166b8ed498ce7d6db7fd5ae70f1761e848c, 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 340de11ee4d6bc5633e6e42d862eaee08bbbe723 && git checkout 340de11ee4d6bc5633e6e42d862eaee08bbbe723
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 13c0b166b8ed498ce7d6db7fd5ae70f1761e848c 4568d6fff2c3cf50d627dc94fa14fc4afe8b1822 && git checkout -B drift-repro 13c0b166b8ed498ce7d6db7fd5ae70f1761e848c && git merge --no-ff 4568d6fff2c3cf50d627dc94fa14fc4afe8b1822
node scripts/docs-audit/affected-docs.mjs --json 13c0b166b8ed498ce7d6db7fd5ae70f1761e848c

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

@github-actions

Copy link
Copy Markdown
Contributor

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

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

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

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 4.79s
    

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

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

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

历史信号:

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

分诊清单:

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

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

Merged via the queue into main with commit 5337ef1Aug 22, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-10946-batch-identity-seed-roundtrips branch August 22, 2026 18:32
@github-actions

Copy link
Copy Markdown
Contributor

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

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

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

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 6.45s
    

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

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

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

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 90 个失败构建(不含本次)。

分诊清单:

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

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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Boot seeds permission sets and positions one at a time: 4 sequential DB round trips each, 2 of them an unconditional UPDATE

1 participant

@os-warren