Skip to content

claimSeedOwnership writes up to 20k single-id system updates in a loop, so per-record sharing materialisation cannot batch them #14530

Description

@claude

Found while implementing #13533 (PR #14528), which removed the isSystem skips from bindRuleHooks so a system write now materialises sharing-rule grants per record, exactly as a user write does. That is correct and was the maintainer's ruling. This card records the one system-write bulk path the census turned up that the hook layer cannot help, so it is triaged on its own rather than folded into the fix.

The shape

claimSeedOwnership (packages/plugins/plugin-security/src/claim-seed-ownership.ts) hands seeded business records to the first platform admin. For every non-sys_, non-managedBy, non-external object that declares owner_id, it runs two scans at limit: 10_000 and then writes each matched id as its own single-id update under { isSystem: true }:

for (const id of ids) {
  await ql.update(schema.name, { id, owner_id: adminUserId }, { context: SYSTEM_CTX });
}

Up to 20,000 ids per object, one write each. Since #13533 every one of those writes on a rule-covered object fires afterInsert/afterUpdate and therefore one evaluateAllForRecord pass.

Why the existing batch tail does not reach it

rule-hooks.ts already has the trailing-batch machinery the #13533 ruling asks for — a write whose row set is unbounded or over RULE_RECOMPUTE_ROW_CAP (1000) takes revokeThenQueueRegrant into evaluateAllRulesForObject. That branch is reached by looking at ONE write's row set. Here each write's row set is legitimately a single row, so the cap never fires: the batch exists only in the caller's loop, which the hook layer cannot see. Making the hooks coalesce across writes is not the answer either — it would make the approval write-back eventually-consistent, which is the defect #13533 just fixed.

Actual exposure, stated honestly

Bounded, and smaller than the raw numbers suggest:

  • runBootstrap fires at kernel:ready, so the boot pass runs before plugin-sharing's kernel:bootstrapped backfillRuleGrants — which reconciles every rule anyway. The per-record work at boot is therefore duplicated, not load-bearing.
  • The pass is also self-limiting: it only matches rows whose owner_id is null or usr_system, so after the first pass there is nothing left to claim.
  • runBootstrap is ALSO replayed at runtime through an ObjectQL middleware (shouldReplayBootstrapFor, security-plugin.ts), and that replay has no trailing backfill behind it — but by then the rows are claimed, so the realistic row count is ~0.

So the worst case is a fresh install with a large seeded, rule-covered dataset paying N per-record sharing evaluations at boot that the boot backfill then repeats. Nothing is incorrect; the grants converge either way.

Possible dispositions, for triage rather than pre-judged

  1. Leave it. The duplication is one-time per boot and the numbers above may make it irrelevant in practice. Cheapest, and honest if nobody has a slow boot.
  2. Make the writer batch: turn the loop into a predicate write per object, which both cuts the write count and lets the existing cap/trailing-batch branch do its job with no change to plugin-sharing.
  3. Give the per-record hook work a boot-phase predicate, the way sharing: deactivating a rule never withdraws its materialized grants — not on touch, not at boot #4433 already did for the sibling rule-reconcile seam in sharing-plugin.ts (ruleGrantsBootReconciled). Deliberately NOT done in 审批回写(系统身份)不触发共享规则物化,「批准后团队看不见」——平台只记一条日志、无补偿、无声明式手段 #13533: a phase flag that never gets set would silently reinstate exactly the defect that card fixed, so it wants its own decision rather than riding along.

Measured while implementing #13533; not reproduced against a timed boot, so there is no latency number here — that measurement is part of whichever disposition triage picks.


Generated by Claude Code

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions