Skip to content

Commit a7a7390

Browse files
os-salesclaude
andauthored
perf(plugin-security): claim seed ownership with one predicate write per unowned shape (#14530) (#14718)
* perf(plugin-security): claim seed ownership with one predicate write per unowned shape Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * chore(changeset): claim-seed-ownership predicate write Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * test(plugin-security): refuse combinators in the claim-seed WHERE double `check:where-matcher` flagged the new fixture matcher as silently wrong on a combinator query. `claimSeedOwnership` issues none, so the double refuses rather than implementing them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * fix(plugin-security): page the seed-ownership predicate write so objects over the per-row hook ceiling are still claimed An unpaged predicate write is refused whole above MAX_BULK_PER_ROW_HOOK_ROWS (ADR-0058 D6), so a 21k-row object claimed nothing where the pre-#14530 loop claimed 10k. `owner_id` is a record-access field, so that is a permission outcome, not an observability one. The unit of work is now a page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * fix(plugin-security): bind the seed-ownership engine calls at the schema.name call site Paging behind an `objectName` parameter made `check:tenant-audit-census` read the write as `undecidable` and took its self-test to 5-of-19 red. Measured: the same file from 39126dc reproduces that on origin/main, and origin/main itself is green. The two engine calls are now bound where `schema.name` is a literal argument, so the census's answer about this file is byte-identical to its pre-change one -- no ledger row degraded to buy a green gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * chore(gates): ratchet the engine-double ledger for the claim-seed-ownership doubles `check:engine-double-contract` reported RETAINED [update] on `claim-seed-ownership.test.ts`: the paging pins grew its engine doubles from 1 to 5, which is the direction this ledger wants, so the gate's own prescribed fix is to regenerate. Regenerated with `--write` on the merged tree (the ledger is contended by sibling PRs), never hand-edited; the regeneration reports "1 added or grown, 0 lost". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * docs(changeset): describe the shape that actually shipped — whole-set write first, page only on refusal The changeset still described the always-paged shape ("read at most 5 000 ids, re-own them, repeat"), which was measured 13x slower on the sizes every real install has and is not what landed. Restated: one predicate write per unowned shape, a page off the top only when the engine refuses that write for its per-row hook budget, plus the re-measured over-ceiling number (21 000 of 21 000 claimed, 8 engine writes) that the paging exists to produce. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent e88d4df commit a7a7390

4 files changed

Lines changed: 757 additions & 71 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
perf(plugin-security): claim seed ownership with a predicate write per object, paged only when the engine refuses it (#14530)
6+
7+
`claimSeedOwnership` — the pass that hands seeded business records to the first
8+
platform admin — scanned every `owner_id`-declaring object twice at
9+
`limit: 10_000` and then issued **one single-id `update` per matched id**: up to
10+
20 000 full engine writes for one object, each paying the whole middleware,
11+
validation and hook chain. The unit of work is now the **set**, not the row: one
12+
predicate write per unowned shape (`owner_id IS NULL`, then
13+
`owner_id = usr_system`), so the matched set is the same set the old two-scan
14+
rule resolved — row for row — while the write count stops scaling with N. The
15+
count reported per object is the sum of the affected-row counts those writes
16+
resolve, never a length this pass counted for itself.
17+
18+
Measured on a real ObjectQL engine (in-memory driver, one sharing-rule-covered
19+
object, shared box): 2 000 rows 2 122 ms to 208 ms; 5 000 rows 10 658 ms to
20+
528 ms, with engine `update` calls falling from N to two per object.
21+
22+
The second half is what the batch buys downstream. plugin-sharing's `rule-hooks`
23+
already routes a write whose row set exceeds `RULE_RECOMPUTE_ROW_CAP` (1 000)
24+
into one set-based revoke plus one queued `evaluateAllRulesForObject`, but that
25+
branch reads **one write's** row set, and every write in the old loop
26+
legitimately carried a single row — so the batch existed only in the caller,
27+
where nothing downstream could see it. Batching here is what lets machinery
28+
already built for this shape do its job; `plugin-sharing` is unchanged.
29+
30+
**And a paged fallback, because one write cannot always carry the set.** A
31+
predicate write carries no `limit`, so the bound becomes the engine's own
32+
`MAX_BULK_PER_ROW_HOOK_ROWS` (10 000): `beforeUpdate` / `afterUpdate` hooks are
33+
contracted to fire per matched row on a predicate write (ADR-0058 D6), and every
34+
object carries such hooks in practice, so the engine refuses an over-sized write
35+
**whole** — nothing written. Measured: 21 000 unowned rows re-owned **nothing**,
36+
where the old loop re-owned 10 000 of them. This pass decides `owner_id`, a
37+
record-access field, so an unclaimed object is a permission outcome and not an
38+
observability detail. The refusal is now answered by taking one page of ids off
39+
the top (half the ceiling) and re-attempting the whole set, until one write can
40+
carry what is left. Re-measured after paging: the same 21 000-row object claims
41+
**all 21 000**, in 8 engine writes and 3 reads.
42+
43+
The order is not cosmetic. Paging unconditionally measured 13x slower on the
44+
sizes every real install has — an `id IN (…)` page is a linear scan of the id
45+
list per row in `InMemoryDriver`, so an always-paged claim is quadratic there
46+
where the natural predicate is linear (5 000 rows: 528 ms whole-set versus
47+
5 865 ms always-paged). The page is what the engine's refusal buys, not the
48+
default.
49+
50+
`patch`: no declared surface moves, no export changes, and the reachable
51+
population strictly grows.

0 commit comments

Comments
 (0)