perf(plugin-security): claim seed ownership with one predicate write per unowned shape (#14530) - #14718

Merged
os-sales merged 11 commits into
mainfrom
claude/issue-14530-claim-seed-ownership-predicate-write
Sep 3, 2026
Merged

perf(plugin-security): claim seed ownership with one predicate write per unowned shape (#14530)#14718
os-sales merged 11 commits into
mainfrom
claude/issue-14530-claim-seed-ownership-predicate-write

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14530

Triage ruled disposition 2 (14530#issuecomment-5508899386): turn claimSeedOwnership's single-id loop into a predicate write per object, take the missing latency measurement first, and confirm at step 3 that the cap / trailing-batch branch in rule-hooks.ts actually engages on the new write shape. All three steps are below, with numbers.

Seat review 14530#issuecomment-5516113880 then ruled patch round 1 into this PR rather than deferring it: an over-ceiling predicate write is refused whole, which took an object above the ceiling from partially claimed to not claimed at all. That is fixed here, and measured. plugin-sharing is untouched throughout — that the fix needs no change there is the whole argument for disposition 2.

The change

claimSeedOwnership scanned every owner_id-declaring object twice at limit: 10_000 and then issued one single-idupdate per matched id — up to 20 000 full engine writes for one object. It now issues one predicate write per unowned shape, and pages that write only when the engine refuses it:

for(constwhereof[{owner_id: null},{owner_id: SystemUserId.SYSTEM}]){constaffected=awaitql.update(name,{owner_id: adminUserId},{ where,multi: true,context: SYSTEM_CTX});}

Two writes per object on every install small enough for them, which in practice is all of them. The scans are gone with the loop: the predicates they resolved are the predicates the writes now carry, so the matched set is unchanged row for row, and the per-object count is the sum of the affected-row counts the writes themselves resolve (#4639) rather than a length this function counted.

The two predicates stay two narrow writes rather than one OR/IN for the reason the scans were two — driver portability — and they stay disjoint in this order, because the NULL write lands adminUserId, which can never be usr_system (that target is refused at the top of the function).

Step 1 — the latency the card said was missing

The card is explicit that there is no number. Here are two, before and after, on a real ObjectQL engine.

Method.new ObjectQL() on InMemoryDriver (persistence: false), one object crm_lead declaring id/name/owner_id, N rows seeded straight through the driver (so setup pays no hook cost) alternating owner_id: null and owner_id: 'usr_system'. plugin-sharing's realbindRuleHooks is bound over a counting SharingRuleService double with one active rule on that object, so every branch of rule-hooks.ts the write shape can reach is counted. The window is claimSeedOwnership itself, process.hrtime.bigint() either side, plus a drain of ruleRegrantQueue. engine.update is wrapped to count the caller's writes. The "before" shape is the pre-change body inlined verbatim from origin/mainc616c2cc2; the "after" shape is imported from the built package.

⚠️Shared box. These are absolutes measured on a container running other agents' builds concurrently, so read the ratios, not the wall clock.

Baseline A — origin/main (c616c2cc2), i.e. before#13533

rowsshapeclaim msengine update callsevaluateAllForRecordskip notices
200before125.120001
200after18.6201
2 000before2 122.62 00001
2 000after170.5201
5 000before10 658.05 00001
5 000after448.0201

6.7x / 12.4x / 23.8x, and the write count stops scaling with N. evaluateAllForRecord: 0 and one skip notice per object are the signature of the pre-#13533 world: bindRuleHooks still opens afterInsert/afterUpdate with if (ctx?.session?.isSystem) { noteSystemWriteSkipped(); return; }, so no sharing materialisation runs at all on this path yet.

Baseline B — PR #14528 head (e9b612a7a), i.e. after#13533

Same harness, same object, run in a separate throwaway worktree at that PR's head with this branch's claim-seed-ownership.ts copied in and rebuilt.

rowsshapeclaim msengine update callsevaluateAllForRecordrevokeRuleGrantsForObjectevaluateAllRulesForObjectskip notices
800before490.0800800000
800after58.32800000
5 000before11 900.65 0005 000000
5 000after489.520220

8.4x and 24.3x. skipNotices: 0 is the discriminator that says these rows were measured in the post-#13533 world.

Step 2 to Step 3 — the cap / trailing-batch branch DOES engage

This is the stop-and-report condition, and it passes — conditionally on #13533, which is the honest reading:

  • 800 rows (400 per predicate, at or under RULE_RECOMPUTE_ROW_CAP = 1 000): the bounded branch runs — evaluateAllForRecord = 800, revokeRuleGrantsForObject = 0. Same per-record grant work as before, from 2 engine writes instead of 800.
  • 5 000 rows (2 500 per predicate, over the cap): readAffectedRows returns over-cap, afterUpdate takes revokeThenQueueRegrant, and the counters read evaluateAllForRecord = 0, revokeRuleGrantsForObject = 2, evaluateAllRulesForObject = 2 — one set-based revoke plus one queued full reconcile per predicate write. That is exactly the branch triage asked to see engaged, reached with no change to plugin-sharing.
  • The old shape never reaches it at any N: each write's row set is one row, affected.kind === 'rows' with a single id, and the cap can never fire. revokeRuleGrantsForObject = 0 at 5 000 rows confirms it.

On today's origin/main the branch is NOT reached#13533's PR #14528 is still open, so the isSystem skip in afterUpdate returns before affectedFrom(ctx) is ever consulted. The write-count and latency win is real and complete today; the cap-branch half arrives with #13533 and is measured above at that PR's head.

Patch round 1 — paging past the engine's per-row hook ceiling

A predicate write carries no limit, so the bound is the engine's own MAX_BULK_PER_ROW_HOOK_ROWS (10 000): beforeUpdate / afterUpdate hooks are contracted to fire per matched row on a predicate write (ADR-0058 D6), and every object carries such hooks in practice, so an over-sized write is refused whole — nothing written. owner_id is a record-access field, so "the object was not claimed" is a permission outcome, not an observability detail.

The refusal is a declared, total verdict whose own message names pagination as the remedy, so it is answered: take one page of ids off the top (CLAIM_PAGE_ROWS, half the ceiling) and re-attempt the whole set. Each page shrinks what is left until one write can carry it, and the pass ends on a whole-set write rather than on a count of pages.

Measured on the same 21 000-row object, one shape per row:

shaperows claimed of 21 000engine writesengine reads
pre-#14530 single-id loop (scans capped at limit: 10_000)10 00010 0002
unpaged predicate write (this PR before patch round 1)020
paged predicate write (this PR now)21 00083

Order is load-bearing, not cosmetic. Paging unconditionally measured 13x slower on the sizes every real install has: an id IN (...) page is evaluated by InMemoryDriver as a linear scan of the id list PER ROW (memory-matcher.ts, target.includes(value)), so an always-paged claim is quadratic there where the natural predicate is linear — 5 000 rows: 528 ms whole-set versus 5 865 ms always-paged, same engine, same driver, same row set. The page is what the engine's refusal buys, not the default.

CLAIM_PAGE_ROWS is derived from the ceiling rather than chosen (half of it), so it moves with the contract: the margin covers a driver's own bound-parameter limit on the id IN (...) list, and half the ceiling is still far above plugin-sharing's 1 000-row recompute cap, so a full page is still seen as a batch by the trailing-batch branch rather than recomputed row by row.

Termination does not rest on the page counter: a page that re-owns rows makes them stop matching the predicate, so the remainder strictly shrinks. MAX_CLAIM_PAGES is the belt for the one shape that reasoning does not cover — a driver reporting an affected count for rows it did not write — and hitting it is warned loudly, never silent. Three separate stop-and-warn paths cover an unreadable affected count, a page that matches rows but re-owns none, and a write that refuses for any code other than the declared per-row-hook budget.

#14719 was filed as the home for this work if paging turned out not to reach it. It did reach it — the number above is 21 000 of 21 000 — so that card is now a PM-seat judgement on this measurement, and this PR does not touch it.

⛔ Raising or exempting MAX_BULK_PER_ROW_HOOK_ROWS was refused: it is imported from @objectstack/spec/data and merely re-exported at engine.ts:7984, so moving it is a packages/spec contract change and the domain:spec seat's alone.

Premise re-verification (the card and triage read 4a37870; main has moved)

reading on origin/mainc616c2cc2
P1 single-id loop still thereclaim-seed-ownership.ts:121-128, the shape quoted verbatim in triage
P2#13533's isSystem removal landed?not landed — PR #14528 is state: open, merged: false; rule-hooks.ts still carries the skip in stashAffectedRows, afterInsert, afterUpdate. Both worlds measured above
P3 cap / trailing-batch machinery intactRULE_RECOMPUTE_ROW_CAP = 1_000; revokeThenQueueRegrant reaches service.evaluateAllRulesForObject(objectName) via ruleRegrantQueue
P4 the three call sites⚠️ partly drifted — see below

P4, precisely.bootstrap-platform-admin.ts:623 (not :533) calls it, via security-plugin.ts:3330. meta resync reaches it indirectly, through bootstrapPlatformAdmin(ql, sets, { resync: true }) at resync.ts:192 — that file names no claimSeedOwnership of its own. And ensure-default-organization.ts:406 is not a call site of this function: it invokes an injectedoptions.claimSeedOwnership with a different signature (ql, organizationId, userId, options), supplied by the enterprise organizations package; auth-plugin.ts:1100 passes { logger } only, so on this repo that hook is never wired.

Both remaining entries also short-circuit at already_have_admin (bootstrap-platform-admin.ts:412) before the claim, so the loop is reachable only while the install has no platform admin. That narrows the exposure the card described — it does not change the fix.

Tests

All figures below are from the final commit 5808133df unless stated.

pnpm --filter @objectstack/plugin-security testTest Files 95 passed (95) · Tests 1795 passed (1795), exit 0.
pnpm --filter @objectstack/plugin-security typecheck — exit 0, verdict line check:test-typecheck: OK — @objectstack/plugin-security's test layer compiles under packages/plugins/plugin-security/tsconfig.test.json; tsc -p tsconfig.test.json --listFiles confirms both edited files are in the program (1 hit each), so that verdict really covers them.
npx eslint . --no-inline-config (the repo-wide scan, not a narrowing) — exit 0, no output, 100 s.
pnpm check:nul-bytescheck-nul-bytes: OK (scanned 8047 text file(s) -- 8047 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).

Gate family re-derived on the final commit with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (44 commands; the derivation's stderr names this repo and commit 5808133df, with no stale-tree warning) and run: 39 green, 5 NOT MEASURED — each reported as such by its own verdict line, never read as green or red:

  • check-test-completeness — exit 3, PREREQUISITE NOT MET — this gate grades a saved 'turbo run test' log, and no log was named.
  • check:dual-build-cjs-loads — exit 3, Run 'pnpm build' first. ⛔ This is NOT a pass: nothing was measured.
  • check:type-check-debt — exit 3, check-type-check-coverage: PREREQUISITE NOT MET
  • check:i18n — exit 1, check-i18n-bundles: PREREQUISITE NOT MET — the workspace CLI is not built
  • scripts/pm/check-half-states.mjs — hit its 300 s bound having printed only re-exec with --use-env-proxy: HTTPS_PROXY is set and node's fetch does not read it; it is a PM tool that reaches GitHub, not a tree gate.

CI runs all five after a full build.

The two gates that were red, and their final verdict lines

check:engine-double-contract was x RETAINED [update]: claim-seed-ownership.test.ts now pins 5 engine double(s), ledger records 1. The gate's own prescribed fix is to ratchet, since coverage grew in the direction the ledger wants. scripts/engine-double-contract.pinned.json is contended by sibling PRs, so origin/main was merged and committed first and the regeneration ran on the merged tree — never hand-edited. Regeneration reported 1 added or grown, 0 lost; the diff is one field, pinned: 1 to pinned: 5. Final verdict line:

check-engine-double-contract: OK — 758 pinned, 134 in the DEBT ledger, 3 exempt.

check-tenant-audit-census is green on both legs, and the self-test: 5 of 19 half was diagnosed rather than assumed:

✓ check-tenant-audit-census: OK -- 217 write call sites certified (145 decidable; 9 tenancy-enabled sites
PROVABLY carry no tenant context, 32 more unreadable), 23 prose figures held to the census.
✓ check-tenant-audit-census self-test: 19 cases pass (...)

The five failures reproduce locally at 39126dc02 and at no other commit measured. They are one root cause, not five: all five are the live-tree family, which asserts the committed census artefacts still match the tree, and at 39126dc02 the tree had drifted (objectName | undecidable where the committed row says schema.name, and the corpus-scale count 15 to 16). They were caused by this PR's diff at that commit and were already answered at 0418ccf8f by binding the engine calls at the schema.name call site, so the census's answer about this file is what it was before the paging fallback existed, with no ledger row degraded to buy a green gate.

Measured, never inferred — the self-test at each commit:

treeself-test
39126dc02 (this branch, before the call-site binding)✗ 5 of 19 failed
0418ccf8f (this branch, after it)✓ 19 pass
75adf11da (origin/main at this branch's base)✓ 19 pass
af44044a8 (origin/main, mid-range)✓ 19 pass
4d0d9445a (origin/main, current then)✓ 19 pass

So it was never red on origin/main, and it is not red now. node scripts/tenant-audit-census.mjs --write on the merged tree rewrites only the explicitly non-enforced corpus-scale block and its measurement date (tracked non-test sources scanned 534 to 539), which moved because main grew files — not because of this diff. That rewrite was reverted rather than committed: the gate is green with the committed values, and the block's own prose says those figures are required to be present and dated, and their values are not compared.

Ablation — three legs, all red, all provably restored

The subject is imported relatively (import { claimSeedOwnership } from './claim-seed-ownership.js'), so vitest compiles the source directly; no dist/ sits in the resolution path, which is the condition scripts/ablation-dist-preflight.mjs states for its own hazard ("any test whose subject resolves through the dependency's exports"). Each leg proves the mutation landed on disk before its colour is read — a globalThis marker (never a comment, which esbuild strips), the removed anchor text counted to 0, and the blob hash moved off HEAD's — and proves the restore landed after — blob hash back to HEAD's d5512db31c4b66c1b0080ec0227c6141ada7d486 and git diff HEAD -- PATH empty. The restore is git checkout HEAD -- ABSOLUTE_PATH, never the bare form, and the driver carries trap restore EXIT INT TERM.

legmutationon-disk proofresult
C — paging removed (new)the per-row-hook refusal rethrows instead of pagingmarker 1, removed anchor 0, hash 4319c4ab... off HEADTests 3 failed / 14 passed (17)
A — back to the pre-#14530 single-id loopreown reads ids and issues one by-id update eachmarker 1, removed anchor 0, hash 2851cc3e...Tests 11 failed / 6 passed (17)
B — matched set narrowed{ owner_id: SystemUserId.SYSTEM } dropped from UNOWNED_PREDICATESmarker 1, removed anchor 0, hash 08a8256b...Tests 10 failed / 7 passed (17)

Leg C is the one that judges patch round 1, and its red set is exactly the paging pins — nothing else moves:

× claims EVERY unowned row past MAX_BULK_PER_ROW_HOOK_ROWS, where one unpaged write is refused whole
× count is the SUM over every write of the pass, not just the last one
× stops rather than spinning when a fallback page matches rows but re-owns none

Legs A and B are the earlier rounds' legs, re-run on the paged implementation to confirm they were not carried off by it. Both still redden the equivalence and shape pins:

× re-owns NULL and usr_system rows to the admin, leaving human-owned rows untouched
× issues ONE predicate write per unowned shape — never one write per row
× claims exactly the id set the pre-#14530 single-id loop would have claimed
× the two predicates stay disjoint — no row is counted twice
× reports the affected-row count the write resolved, not a length it counted itself
× says "unknown" rather than 0 when a driver resolves something that is not a count
× a refused predicate write costs that predicate only — never the object or the run

The equivalence pin re-states the OLD rule (two scans at limit: 10_000, deduped) rather than calling into the implementation under test, on a fixture that includes an absent column, a present-but-undefined, an empty string and a usr_system_admin prefix collision, and asserts the new write claims that id set exactly. The over-ceiling pin compares against the uncapped unowned set instead, because past 10 000 the old rule was itself lossy and the new one must beat it, not match it.

Clause ②: no

Re-derived from the current diff on the final commit:

$ git diff -U0 origin/main...HEAD | grep -E '^\+\s*export '
$ echo $?
1

No new exported symbol. grep export cannot see a changed signature on an already-exported declaration, so that was checked separately: claimSeedOwnership is the file's only export, and its signature is byte-identical to origin/main's — (ql: any, adminUserId: string, options: ClaimOwnershipOptions = {}): Promise SUMMARY_ARRAY, where the summary element type { object: string; count: number } is also unchanged. CLAIM_PAGE_ROWS, MAX_CLAIM_PAGES, UNOWNED_PREDICATES, ObjectWriter, claimPredicate, isPerRowHookBudgetRefusal, affectedRowCount and idsFrom are all module-private. No carrier is owed.

One accept-set note that is not an export: the typeof ql.find !== 'function' precondition is retained, because the paged fallback reads.

Not done, deliberately

Disposition 3 (a boot-phase predicate) is out of scope and not this seat's to authorise; the hooks are not made to coalesce across writes; packages/spec is not touched.

⚠️packages/cli's run-dev-unbuilt-workspace.e2e.test.ts is a known repo-wide flake (#14648 / #14727, domain:cli is on it). It is unrelated to this branch and is neither fixed nor skipped here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

os-salesand others added 4 commits September 2, 2026 19:25
…per unowned shape
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
`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
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

10 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

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 dbf115284295b1989d4648dbfbd7e5f3f96357dcpackageMentionDocs.

Which tree this was computed on

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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI red on 9f17ab8a0 — the tracked run-dev-unbuilt-workspace flake, not this PR's

Test Core (1/6), read from the job log:

::error file=packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts,
line=317: AssertionError: expected 'SIGKILL' to be null
Duration 945.51s (import 373.97s, tests 2420.53s)

Not this PR's. The diff is three files, all in @objectstack/plugin-security (claim-seed-ownership.ts, its test, one changeset) — it touches nothing in @objectstack/cli, nothing the CLI boots, and no shared script that test loads. The failing test is the tracked flake anchored at #14648 (priority:p1, domain:cli, dispatched, dev on it since 18:15Z), and the asserted value is the harness's own kill signal firing at its 40 s cap — a cap derived from a measured ~22 s uncontended run, against a shard that here spent 2420 s of test time. A stopwatch, not a behaviour.

This is the fourth PR from this seat that the same signature has taken (#14528 and #14687 in the merge queue, #14712 and now this one on branch CI).

No re-run spent and nothing pushed for it. Patch round 1 is in flight on this branch — the seat's review is at 14530#issuecomment-5516113880 and requires the predicate write to be paged so objects above MAX_BULK_PER_ROW_HOOK_ROWS are still claimed — so the head moves shortly and CI re-runs on its own.

@objectstack/plugin-security's own suite was green on this head: Test Files 95 passed (95) · Tests 1791 passed (1791).


Generated by Claude Code

…cts 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
…ema.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
@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Blocker record — this PR's own gates, and what is queued behind them

Putting this on the PR because the seat's instructions to the dev agent went out over a channel that did not survive: the container running this session restarted at ~21:45Z and killed the agent working on this branch. Nothing was lost from the branch (its head 0418ccf8f was already pushed), but the in-flight instructions died with the agent, so they are restated here where they persist.

Gate 1 — check-tenant-audit-census, red at 39126dc02, addressed at 0418ccf8f

census : | packages/plugins/plugin-security/src/claim-seed-ownership.ts | update | objectName | undecidable | elevated | 1 |
Fix: node scripts/tenant-audit-census.mjs --write
✗ check-tenant-audit-census self-test: 5 of 19 case(s) failed.

The seat's ruling on it, restated: ⛔ do not run --write and call it done.objectName going undecidable is a signal, not a chore — before the paging change that write site's object name was statically decidable, and recording the downgrade trades the census's proving power for a green light. The right first question is whether the write site can stay decidable. The commit 0418ccf8f ("bind the seed-ownership engine calls at the schema.name call site") is exactly that answer, and it is the right one.

The self-test: 5 of 19 half is a separate question and must not be assumed to travel with it: read the full job output, and for each of the five decide whether this diff falsified its premise or whether it is red on origin/main too — that has to be measured on origin/main, never inferred. If it is red there, it is not this PR's.

⚠️ Note the gate's own words: "selfTest() returned without reaching its verdict, so no success line was printed." By this repo's standing rule, no verdict line means it did not pass — it cannot be read as green.

Gate 2 — check-engine-double-contract, red now at 0418ccf8f

x RETAINED [update]: packages/plugins/plugin-security/src/claim-seed-ownership.test.ts now pins 5 engine double(s), ledger records 1.
Coverage grew, which is the direction this ledger wants — run
`node scripts/check-engine-double-contract.mjs --write` and commit so the new double is ratcheted too.
check-engine-double-contract: 1 problem(s).

This one is the mechanical case: the gate says coverage grew in the direction the ledger wants, and ratcheting is the prescribed fix. Unlike gate 1, there is no downgrade being recorded.

⚠️But scripts/engine-double-contract.pinned.json is three-way contended right now — this PR, #14528 and #14726 all write it. So the ratchet is not a standalone --write: merge origin/main first, commit the merge, then regenerate on the merged tree and let check:engine-double-contract be the proof. ⛔ Never hand-edit the ledger.

(The two unrecognised [findOne] lines name other files and are not counted in the gate's single problem; they need no action here.)

What is not blocked

The paging work itself measured well and is not in question: 5000 rows went from 10 658 ms / 5000 engine writes to 448 ms / 2 writes, the cap / trailing-batch branch was measured to engage over-cap with zero change to plugin-sharing, and the P2 premise came back false and was handled by measuring both worlds with the per-object skip notice as the discriminator. The seat's review is at 14530#issuecomment-5516113880; the one substantive change still owed there is paging the predicate write so objects above MAX_BULK_PER_ROW_HOOK_ROWS are claimed rather than refused whole.

Queue position, stated so the wait is a record

This seat is at the maintainer's dispatch cap of 3 running dev agents (two post-restart recoveries plus PR #14528's merge round). This PR's remaining work is next after PR #14726's patch round. ⛔ No manual re-run is being spent on the run-dev-unbuilt-workspace flake that also hits this branch — that is #14648's, and it is already dispatched in domain:cli.


Generated by Claude Code

…ership 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
… 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
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.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

perf(plugin-security): claim seed ownership with one predicate write per unowned shape (#14530) - #14718

Merged
os-sales merged 11 commits into
mainfrom
claude/issue-14530-claim-seed-ownership-predicate-write
Sep 3, 2026
Merged

perf(plugin-security): claim seed ownership with one predicate write per unowned shape (#14530)#14718
os-sales merged 11 commits into
mainfrom
claude/issue-14530-claim-seed-ownership-predicate-write

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14530

Triage ruled disposition 2 (14530#issuecomment-5508899386): turn claimSeedOwnership's single-id loop into a predicate write per object, take the missing latency measurement first, and confirm at step 3 that the cap / trailing-batch branch in rule-hooks.ts actually engages on the new write shape. All three steps are below, with numbers.

Seat review 14530#issuecomment-5516113880 then ruled patch round 1 into this PR rather than deferring it: an over-ceiling predicate write is refused whole, which took an object above the ceiling from partially claimed to not claimed at all. That is fixed here, and measured. plugin-sharing is untouched throughout — that the fix needs no change there is the whole argument for disposition 2.

The change

claimSeedOwnership scanned every owner_id-declaring object twice at limit: 10_000 and then issued one single-idupdate per matched id — up to 20 000 full engine writes for one object. It now issues one predicate write per unowned shape, and pages that write only when the engine refuses it:

for(constwhereof[{owner_id: null},{owner_id: SystemUserId.SYSTEM}]){constaffected=awaitql.update(name,{owner_id: adminUserId},{ where,multi: true,context: SYSTEM_CTX});}

Two writes per object on every install small enough for them, which in practice is all of them. The scans are gone with the loop: the predicates they resolved are the predicates the writes now carry, so the matched set is unchanged row for row, and the per-object count is the sum of the affected-row counts the writes themselves resolve (#4639) rather than a length this function counted.

The two predicates stay two narrow writes rather than one OR/IN for the reason the scans were two — driver portability — and they stay disjoint in this order, because the NULL write lands adminUserId, which can never be usr_system (that target is refused at the top of the function).

Step 1 — the latency the card said was missing

The card is explicit that there is no number. Here are two, before and after, on a real ObjectQL engine.

Method.new ObjectQL() on InMemoryDriver (persistence: false), one object crm_lead declaring id/name/owner_id, N rows seeded straight through the driver (so setup pays no hook cost) alternating owner_id: null and owner_id: 'usr_system'. plugin-sharing's realbindRuleHooks is bound over a counting SharingRuleService double with one active rule on that object, so every branch of rule-hooks.ts the write shape can reach is counted. The window is claimSeedOwnership itself, process.hrtime.bigint() either side, plus a drain of ruleRegrantQueue. engine.update is wrapped to count the caller's writes. The "before" shape is the pre-change body inlined verbatim from origin/mainc616c2cc2; the "after" shape is imported from the built package.

⚠️Shared box. These are absolutes measured on a container running other agents' builds concurrently, so read the ratios, not the wall clock.

Baseline A — origin/main (c616c2cc2), i.e. before#13533

rowsshapeclaim msengine update callsevaluateAllForRecordskip notices
200before125.120001
200after18.6201
2 000before2 122.62 00001
2 000after170.5201
5 000before10 658.05 00001
5 000after448.0201

6.7x / 12.4x / 23.8x, and the write count stops scaling with N. evaluateAllForRecord: 0 and one skip notice per object are the signature of the pre-#13533 world: bindRuleHooks still opens afterInsert/afterUpdate with if (ctx?.session?.isSystem) { noteSystemWriteSkipped(); return; }, so no sharing materialisation runs at all on this path yet.

Baseline B — PR #14528 head (e9b612a7a), i.e. after#13533

Same harness, same object, run in a separate throwaway worktree at that PR's head with this branch's claim-seed-ownership.ts copied in and rebuilt.

rowsshapeclaim msengine update callsevaluateAllForRecordrevokeRuleGrantsForObjectevaluateAllRulesForObjectskip notices
800before490.0800800000
800after58.32800000
5 000before11 900.65 0005 000000
5 000after489.520220

8.4x and 24.3x. skipNotices: 0 is the discriminator that says these rows were measured in the post-#13533 world.

Step 2 to Step 3 — the cap / trailing-batch branch DOES engage

This is the stop-and-report condition, and it passes — conditionally on #13533, which is the honest reading:

  • 800 rows (400 per predicate, at or under RULE_RECOMPUTE_ROW_CAP = 1 000): the bounded branch runs — evaluateAllForRecord = 800, revokeRuleGrantsForObject = 0. Same per-record grant work as before, from 2 engine writes instead of 800.
  • 5 000 rows (2 500 per predicate, over the cap): readAffectedRows returns over-cap, afterUpdate takes revokeThenQueueRegrant, and the counters read evaluateAllForRecord = 0, revokeRuleGrantsForObject = 2, evaluateAllRulesForObject = 2 — one set-based revoke plus one queued full reconcile per predicate write. That is exactly the branch triage asked to see engaged, reached with no change to plugin-sharing.
  • The old shape never reaches it at any N: each write's row set is one row, affected.kind === 'rows' with a single id, and the cap can never fire. revokeRuleGrantsForObject = 0 at 5 000 rows confirms it.

On today's origin/main the branch is NOT reached#13533's PR #14528 is still open, so the isSystem skip in afterUpdate returns before affectedFrom(ctx) is ever consulted. The write-count and latency win is real and complete today; the cap-branch half arrives with #13533 and is measured above at that PR's head.

Patch round 1 — paging past the engine's per-row hook ceiling

A predicate write carries no limit, so the bound is the engine's own MAX_BULK_PER_ROW_HOOK_ROWS (10 000): beforeUpdate / afterUpdate hooks are contracted to fire per matched row on a predicate write (ADR-0058 D6), and every object carries such hooks in practice, so an over-sized write is refused whole — nothing written. owner_id is a record-access field, so "the object was not claimed" is a permission outcome, not an observability detail.

The refusal is a declared, total verdict whose own message names pagination as the remedy, so it is answered: take one page of ids off the top (CLAIM_PAGE_ROWS, half the ceiling) and re-attempt the whole set. Each page shrinks what is left until one write can carry it, and the pass ends on a whole-set write rather than on a count of pages.

Measured on the same 21 000-row object, one shape per row:

shaperows claimed of 21 000engine writesengine reads
pre-#14530 single-id loop (scans capped at limit: 10_000)10 00010 0002
unpaged predicate write (this PR before patch round 1)020
paged predicate write (this PR now)21 00083

Order is load-bearing, not cosmetic. Paging unconditionally measured 13x slower on the sizes every real install has: an id IN (...) page is evaluated by InMemoryDriver as a linear scan of the id list PER ROW (memory-matcher.ts, target.includes(value)), so an always-paged claim is quadratic there where the natural predicate is linear — 5 000 rows: 528 ms whole-set versus 5 865 ms always-paged, same engine, same driver, same row set. The page is what the engine's refusal buys, not the default.

CLAIM_PAGE_ROWS is derived from the ceiling rather than chosen (half of it), so it moves with the contract: the margin covers a driver's own bound-parameter limit on the id IN (...) list, and half the ceiling is still far above plugin-sharing's 1 000-row recompute cap, so a full page is still seen as a batch by the trailing-batch branch rather than recomputed row by row.

Termination does not rest on the page counter: a page that re-owns rows makes them stop matching the predicate, so the remainder strictly shrinks. MAX_CLAIM_PAGES is the belt for the one shape that reasoning does not cover — a driver reporting an affected count for rows it did not write — and hitting it is warned loudly, never silent. Three separate stop-and-warn paths cover an unreadable affected count, a page that matches rows but re-owns none, and a write that refuses for any code other than the declared per-row-hook budget.

#14719 was filed as the home for this work if paging turned out not to reach it. It did reach it — the number above is 21 000 of 21 000 — so that card is now a PM-seat judgement on this measurement, and this PR does not touch it.

⛔ Raising or exempting MAX_BULK_PER_ROW_HOOK_ROWS was refused: it is imported from @objectstack/spec/data and merely re-exported at engine.ts:7984, so moving it is a packages/spec contract change and the domain:spec seat's alone.

Premise re-verification (the card and triage read 4a37870; main has moved)

reading on origin/mainc616c2cc2
P1 single-id loop still thereclaim-seed-ownership.ts:121-128, the shape quoted verbatim in triage
P2#13533's isSystem removal landed?not landed — PR #14528 is state: open, merged: false; rule-hooks.ts still carries the skip in stashAffectedRows, afterInsert, afterUpdate. Both worlds measured above
P3 cap / trailing-batch machinery intactRULE_RECOMPUTE_ROW_CAP = 1_000; revokeThenQueueRegrant reaches service.evaluateAllRulesForObject(objectName) via ruleRegrantQueue
P4 the three call sites⚠️ partly drifted — see below

P4, precisely.bootstrap-platform-admin.ts:623 (not :533) calls it, via security-plugin.ts:3330. meta resync reaches it indirectly, through bootstrapPlatformAdmin(ql, sets, { resync: true }) at resync.ts:192 — that file names no claimSeedOwnership of its own. And ensure-default-organization.ts:406 is not a call site of this function: it invokes an injectedoptions.claimSeedOwnership with a different signature (ql, organizationId, userId, options), supplied by the enterprise organizations package; auth-plugin.ts:1100 passes { logger } only, so on this repo that hook is never wired.

Both remaining entries also short-circuit at already_have_admin (bootstrap-platform-admin.ts:412) before the claim, so the loop is reachable only while the install has no platform admin. That narrows the exposure the card described — it does not change the fix.

Tests

All figures below are from the final commit 5808133df unless stated.

pnpm --filter @objectstack/plugin-security testTest Files 95 passed (95) · Tests 1795 passed (1795), exit 0.
pnpm --filter @objectstack/plugin-security typecheck — exit 0, verdict line check:test-typecheck: OK — @objectstack/plugin-security's test layer compiles under packages/plugins/plugin-security/tsconfig.test.json; tsc -p tsconfig.test.json --listFiles confirms both edited files are in the program (1 hit each), so that verdict really covers them.
npx eslint . --no-inline-config (the repo-wide scan, not a narrowing) — exit 0, no output, 100 s.
pnpm check:nul-bytescheck-nul-bytes: OK (scanned 8047 text file(s) -- 8047 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).

Gate family re-derived on the final commit with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (44 commands; the derivation's stderr names this repo and commit 5808133df, with no stale-tree warning) and run: 39 green, 5 NOT MEASURED — each reported as such by its own verdict line, never read as green or red:

  • check-test-completeness — exit 3, PREREQUISITE NOT MET — this gate grades a saved 'turbo run test' log, and no log was named.
  • check:dual-build-cjs-loads — exit 3, Run 'pnpm build' first. ⛔ This is NOT a pass: nothing was measured.
  • check:type-check-debt — exit 3, check-type-check-coverage: PREREQUISITE NOT MET
  • check:i18n — exit 1, check-i18n-bundles: PREREQUISITE NOT MET — the workspace CLI is not built
  • scripts/pm/check-half-states.mjs — hit its 300 s bound having printed only re-exec with --use-env-proxy: HTTPS_PROXY is set and node's fetch does not read it; it is a PM tool that reaches GitHub, not a tree gate.

CI runs all five after a full build.

The two gates that were red, and their final verdict lines

check:engine-double-contract was x RETAINED [update]: claim-seed-ownership.test.ts now pins 5 engine double(s), ledger records 1. The gate's own prescribed fix is to ratchet, since coverage grew in the direction the ledger wants. scripts/engine-double-contract.pinned.json is contended by sibling PRs, so origin/main was merged and committed first and the regeneration ran on the merged tree — never hand-edited. Regeneration reported 1 added or grown, 0 lost; the diff is one field, pinned: 1 to pinned: 5. Final verdict line:

check-engine-double-contract: OK — 758 pinned, 134 in the DEBT ledger, 3 exempt.

check-tenant-audit-census is green on both legs, and the self-test: 5 of 19 half was diagnosed rather than assumed:

✓ check-tenant-audit-census: OK -- 217 write call sites certified (145 decidable; 9 tenancy-enabled sites
PROVABLY carry no tenant context, 32 more unreadable), 23 prose figures held to the census.
✓ check-tenant-audit-census self-test: 19 cases pass (...)

The five failures reproduce locally at 39126dc02 and at no other commit measured. They are one root cause, not five: all five are the live-tree family, which asserts the committed census artefacts still match the tree, and at 39126dc02 the tree had drifted (objectName | undecidable where the committed row says schema.name, and the corpus-scale count 15 to 16). They were caused by this PR's diff at that commit and were already answered at 0418ccf8f by binding the engine calls at the schema.name call site, so the census's answer about this file is what it was before the paging fallback existed, with no ledger row degraded to buy a green gate.

Measured, never inferred — the self-test at each commit:

treeself-test
39126dc02 (this branch, before the call-site binding)✗ 5 of 19 failed
0418ccf8f (this branch, after it)✓ 19 pass
75adf11da (origin/main at this branch's base)✓ 19 pass
af44044a8 (origin/main, mid-range)✓ 19 pass
4d0d9445a (origin/main, current then)✓ 19 pass

So it was never red on origin/main, and it is not red now. node scripts/tenant-audit-census.mjs --write on the merged tree rewrites only the explicitly non-enforced corpus-scale block and its measurement date (tracked non-test sources scanned 534 to 539), which moved because main grew files — not because of this diff. That rewrite was reverted rather than committed: the gate is green with the committed values, and the block's own prose says those figures are required to be present and dated, and their values are not compared.

Ablation — three legs, all red, all provably restored

The subject is imported relatively (import { claimSeedOwnership } from './claim-seed-ownership.js'), so vitest compiles the source directly; no dist/ sits in the resolution path, which is the condition scripts/ablation-dist-preflight.mjs states for its own hazard ("any test whose subject resolves through the dependency's exports"). Each leg proves the mutation landed on disk before its colour is read — a globalThis marker (never a comment, which esbuild strips), the removed anchor text counted to 0, and the blob hash moved off HEAD's — and proves the restore landed after — blob hash back to HEAD's d5512db31c4b66c1b0080ec0227c6141ada7d486 and git diff HEAD -- PATH empty. The restore is git checkout HEAD -- ABSOLUTE_PATH, never the bare form, and the driver carries trap restore EXIT INT TERM.

legmutationon-disk proofresult
C — paging removed (new)the per-row-hook refusal rethrows instead of pagingmarker 1, removed anchor 0, hash 4319c4ab... off HEADTests 3 failed / 14 passed (17)
A — back to the pre-#14530 single-id loopreown reads ids and issues one by-id update eachmarker 1, removed anchor 0, hash 2851cc3e...Tests 11 failed / 6 passed (17)
B — matched set narrowed{ owner_id: SystemUserId.SYSTEM } dropped from UNOWNED_PREDICATESmarker 1, removed anchor 0, hash 08a8256b...Tests 10 failed / 7 passed (17)

Leg C is the one that judges patch round 1, and its red set is exactly the paging pins — nothing else moves:

× claims EVERY unowned row past MAX_BULK_PER_ROW_HOOK_ROWS, where one unpaged write is refused whole
× count is the SUM over every write of the pass, not just the last one
× stops rather than spinning when a fallback page matches rows but re-owns none

Legs A and B are the earlier rounds' legs, re-run on the paged implementation to confirm they were not carried off by it. Both still redden the equivalence and shape pins:

× re-owns NULL and usr_system rows to the admin, leaving human-owned rows untouched
× issues ONE predicate write per unowned shape — never one write per row
× claims exactly the id set the pre-#14530 single-id loop would have claimed
× the two predicates stay disjoint — no row is counted twice
× reports the affected-row count the write resolved, not a length it counted itself
× says "unknown" rather than 0 when a driver resolves something that is not a count
× a refused predicate write costs that predicate only — never the object or the run

The equivalence pin re-states the OLD rule (two scans at limit: 10_000, deduped) rather than calling into the implementation under test, on a fixture that includes an absent column, a present-but-undefined, an empty string and a usr_system_admin prefix collision, and asserts the new write claims that id set exactly. The over-ceiling pin compares against the uncapped unowned set instead, because past 10 000 the old rule was itself lossy and the new one must beat it, not match it.

Clause ②: no

Re-derived from the current diff on the final commit:

$ git diff -U0 origin/main...HEAD | grep -E '^\+\s*export '
$ echo $?
1

No new exported symbol. grep export cannot see a changed signature on an already-exported declaration, so that was checked separately: claimSeedOwnership is the file's only export, and its signature is byte-identical to origin/main's — (ql: any, adminUserId: string, options: ClaimOwnershipOptions = {}): Promise SUMMARY_ARRAY, where the summary element type { object: string; count: number } is also unchanged. CLAIM_PAGE_ROWS, MAX_CLAIM_PAGES, UNOWNED_PREDICATES, ObjectWriter, claimPredicate, isPerRowHookBudgetRefusal, affectedRowCount and idsFrom are all module-private. No carrier is owed.

One accept-set note that is not an export: the typeof ql.find !== 'function' precondition is retained, because the paged fallback reads.

Not done, deliberately

Disposition 3 (a boot-phase predicate) is out of scope and not this seat's to authorise; the hooks are not made to coalesce across writes; packages/spec is not touched.

⚠️packages/cli's run-dev-unbuilt-workspace.e2e.test.ts is a known repo-wide flake (#14648 / #14727, domain:cli is on it). It is unrelated to this branch and is neither fixed nor skipped here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

os-salesand others added 4 commits September 2, 2026 19:25
…per unowned shape
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
`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
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

10 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

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 dbf115284295b1989d4648dbfbd7e5f3f96357dcpackageMentionDocs.

Which tree this was computed on

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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI red on 9f17ab8a0 — the tracked run-dev-unbuilt-workspace flake, not this PR's

Test Core (1/6), read from the job log:

::error file=packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts,
line=317: AssertionError: expected 'SIGKILL' to be null
Duration 945.51s (import 373.97s, tests 2420.53s)

Not this PR's. The diff is three files, all in @objectstack/plugin-security (claim-seed-ownership.ts, its test, one changeset) — it touches nothing in @objectstack/cli, nothing the CLI boots, and no shared script that test loads. The failing test is the tracked flake anchored at #14648 (priority:p1, domain:cli, dispatched, dev on it since 18:15Z), and the asserted value is the harness's own kill signal firing at its 40 s cap — a cap derived from a measured ~22 s uncontended run, against a shard that here spent 2420 s of test time. A stopwatch, not a behaviour.

This is the fourth PR from this seat that the same signature has taken (#14528 and #14687 in the merge queue, #14712 and now this one on branch CI).

No re-run spent and nothing pushed for it. Patch round 1 is in flight on this branch — the seat's review is at 14530#issuecomment-5516113880 and requires the predicate write to be paged so objects above MAX_BULK_PER_ROW_HOOK_ROWS are still claimed — so the head moves shortly and CI re-runs on its own.

@objectstack/plugin-security's own suite was green on this head: Test Files 95 passed (95) · Tests 1791 passed (1791).


Generated by Claude Code

…cts 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
…ema.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
@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Blocker record — this PR's own gates, and what is queued behind them

Putting this on the PR because the seat's instructions to the dev agent went out over a channel that did not survive: the container running this session restarted at ~21:45Z and killed the agent working on this branch. Nothing was lost from the branch (its head 0418ccf8f was already pushed), but the in-flight instructions died with the agent, so they are restated here where they persist.

Gate 1 — check-tenant-audit-census, red at 39126dc02, addressed at 0418ccf8f

census : | packages/plugins/plugin-security/src/claim-seed-ownership.ts | update | objectName | undecidable | elevated | 1 |
Fix: node scripts/tenant-audit-census.mjs --write
✗ check-tenant-audit-census self-test: 5 of 19 case(s) failed.

The seat's ruling on it, restated: ⛔ do not run --write and call it done.objectName going undecidable is a signal, not a chore — before the paging change that write site's object name was statically decidable, and recording the downgrade trades the census's proving power for a green light. The right first question is whether the write site can stay decidable. The commit 0418ccf8f ("bind the seed-ownership engine calls at the schema.name call site") is exactly that answer, and it is the right one.

The self-test: 5 of 19 half is a separate question and must not be assumed to travel with it: read the full job output, and for each of the five decide whether this diff falsified its premise or whether it is red on origin/main too — that has to be measured on origin/main, never inferred. If it is red there, it is not this PR's.

⚠️ Note the gate's own words: "selfTest() returned without reaching its verdict, so no success line was printed." By this repo's standing rule, no verdict line means it did not pass — it cannot be read as green.

Gate 2 — check-engine-double-contract, red now at 0418ccf8f

x RETAINED [update]: packages/plugins/plugin-security/src/claim-seed-ownership.test.ts now pins 5 engine double(s), ledger records 1.
Coverage grew, which is the direction this ledger wants — run
`node scripts/check-engine-double-contract.mjs --write` and commit so the new double is ratcheted too.
check-engine-double-contract: 1 problem(s).

This one is the mechanical case: the gate says coverage grew in the direction the ledger wants, and ratcheting is the prescribed fix. Unlike gate 1, there is no downgrade being recorded.

⚠️But scripts/engine-double-contract.pinned.json is three-way contended right now — this PR, #14528 and #14726 all write it. So the ratchet is not a standalone --write: merge origin/main first, commit the merge, then regenerate on the merged tree and let check:engine-double-contract be the proof. ⛔ Never hand-edit the ledger.

(The two unrecognised [findOne] lines name other files and are not counted in the gate's single problem; they need no action here.)

What is not blocked

The paging work itself measured well and is not in question: 5000 rows went from 10 658 ms / 5000 engine writes to 448 ms / 2 writes, the cap / trailing-batch branch was measured to engage over-cap with zero change to plugin-sharing, and the P2 premise came back false and was handled by measuring both worlds with the per-object skip notice as the discriminator. The seat's review is at 14530#issuecomment-5516113880; the one substantive change still owed there is paging the predicate write so objects above MAX_BULK_PER_ROW_HOOK_ROWS are claimed rather than refused whole.

Queue position, stated so the wait is a record

This seat is at the maintainer's dispatch cap of 3 running dev agents (two post-restart recoveries plus PR #14528's merge round). This PR's remaining work is next after PR #14726's patch round. ⛔ No manual re-run is being spent on the run-dev-unbuilt-workspace flake that also hits this branch — that is #14648's, and it is already dispatched in domain:cli.


Generated by Claude Code

…ership 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
… 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
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.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf(plugin-security): claim seed ownership with one predicate write per unowned shape (#14530) - #14718

Merged
os-sales merged 11 commits into
mainfrom
claude/issue-14530-claim-seed-ownership-predicate-write
Sep 3, 2026
Merged

perf(plugin-security): claim seed ownership with one predicate write per unowned shape (#14530)#14718
os-sales merged 11 commits into
mainfrom
claude/issue-14530-claim-seed-ownership-predicate-write

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14530

Triage ruled disposition 2 (14530#issuecomment-5508899386): turn claimSeedOwnership's single-id loop into a predicate write per object, take the missing latency measurement first, and confirm at step 3 that the cap / trailing-batch branch in rule-hooks.ts actually engages on the new write shape. All three steps are below, with numbers.

Seat review 14530#issuecomment-5516113880 then ruled patch round 1 into this PR rather than deferring it: an over-ceiling predicate write is refused whole, which took an object above the ceiling from partially claimed to not claimed at all. That is fixed here, and measured. plugin-sharing is untouched throughout — that the fix needs no change there is the whole argument for disposition 2.

The change

claimSeedOwnership scanned every owner_id-declaring object twice at limit: 10_000 and then issued one single-idupdate per matched id — up to 20 000 full engine writes for one object. It now issues one predicate write per unowned shape, and pages that write only when the engine refuses it:

for(constwhereof[{owner_id: null},{owner_id: SystemUserId.SYSTEM}]){constaffected=awaitql.update(name,{owner_id: adminUserId},{ where,multi: true,context: SYSTEM_CTX});}

Two writes per object on every install small enough for them, which in practice is all of them. The scans are gone with the loop: the predicates they resolved are the predicates the writes now carry, so the matched set is unchanged row for row, and the per-object count is the sum of the affected-row counts the writes themselves resolve (#4639) rather than a length this function counted.

The two predicates stay two narrow writes rather than one OR/IN for the reason the scans were two — driver portability — and they stay disjoint in this order, because the NULL write lands adminUserId, which can never be usr_system (that target is refused at the top of the function).

Step 1 — the latency the card said was missing

The card is explicit that there is no number. Here are two, before and after, on a real ObjectQL engine.

Method.new ObjectQL() on InMemoryDriver (persistence: false), one object crm_lead declaring id/name/owner_id, N rows seeded straight through the driver (so setup pays no hook cost) alternating owner_id: null and owner_id: 'usr_system'. plugin-sharing's realbindRuleHooks is bound over a counting SharingRuleService double with one active rule on that object, so every branch of rule-hooks.ts the write shape can reach is counted. The window is claimSeedOwnership itself, process.hrtime.bigint() either side, plus a drain of ruleRegrantQueue. engine.update is wrapped to count the caller's writes. The "before" shape is the pre-change body inlined verbatim from origin/mainc616c2cc2; the "after" shape is imported from the built package.

⚠️Shared box. These are absolutes measured on a container running other agents' builds concurrently, so read the ratios, not the wall clock.

Baseline A — origin/main (c616c2cc2), i.e. before#13533

rowsshapeclaim msengine update callsevaluateAllForRecordskip notices
200before125.120001
200after18.6201
2 000before2 122.62 00001
2 000after170.5201
5 000before10 658.05 00001
5 000after448.0201

6.7x / 12.4x / 23.8x, and the write count stops scaling with N. evaluateAllForRecord: 0 and one skip notice per object are the signature of the pre-#13533 world: bindRuleHooks still opens afterInsert/afterUpdate with if (ctx?.session?.isSystem) { noteSystemWriteSkipped(); return; }, so no sharing materialisation runs at all on this path yet.

Baseline B — PR #14528 head (e9b612a7a), i.e. after#13533

Same harness, same object, run in a separate throwaway worktree at that PR's head with this branch's claim-seed-ownership.ts copied in and rebuilt.

rowsshapeclaim msengine update callsevaluateAllForRecordrevokeRuleGrantsForObjectevaluateAllRulesForObjectskip notices
800before490.0800800000
800after58.32800000
5 000before11 900.65 0005 000000
5 000after489.520220

8.4x and 24.3x. skipNotices: 0 is the discriminator that says these rows were measured in the post-#13533 world.

Step 2 to Step 3 — the cap / trailing-batch branch DOES engage

This is the stop-and-report condition, and it passes — conditionally on #13533, which is the honest reading:

  • 800 rows (400 per predicate, at or under RULE_RECOMPUTE_ROW_CAP = 1 000): the bounded branch runs — evaluateAllForRecord = 800, revokeRuleGrantsForObject = 0. Same per-record grant work as before, from 2 engine writes instead of 800.
  • 5 000 rows (2 500 per predicate, over the cap): readAffectedRows returns over-cap, afterUpdate takes revokeThenQueueRegrant, and the counters read evaluateAllForRecord = 0, revokeRuleGrantsForObject = 2, evaluateAllRulesForObject = 2 — one set-based revoke plus one queued full reconcile per predicate write. That is exactly the branch triage asked to see engaged, reached with no change to plugin-sharing.
  • The old shape never reaches it at any N: each write's row set is one row, affected.kind === 'rows' with a single id, and the cap can never fire. revokeRuleGrantsForObject = 0 at 5 000 rows confirms it.

On today's origin/main the branch is NOT reached#13533's PR #14528 is still open, so the isSystem skip in afterUpdate returns before affectedFrom(ctx) is ever consulted. The write-count and latency win is real and complete today; the cap-branch half arrives with #13533 and is measured above at that PR's head.

Patch round 1 — paging past the engine's per-row hook ceiling

A predicate write carries no limit, so the bound is the engine's own MAX_BULK_PER_ROW_HOOK_ROWS (10 000): beforeUpdate / afterUpdate hooks are contracted to fire per matched row on a predicate write (ADR-0058 D6), and every object carries such hooks in practice, so an over-sized write is refused whole — nothing written. owner_id is a record-access field, so "the object was not claimed" is a permission outcome, not an observability detail.

The refusal is a declared, total verdict whose own message names pagination as the remedy, so it is answered: take one page of ids off the top (CLAIM_PAGE_ROWS, half the ceiling) and re-attempt the whole set. Each page shrinks what is left until one write can carry it, and the pass ends on a whole-set write rather than on a count of pages.

Measured on the same 21 000-row object, one shape per row:

shaperows claimed of 21 000engine writesengine reads
pre-#14530 single-id loop (scans capped at limit: 10_000)10 00010 0002
unpaged predicate write (this PR before patch round 1)020
paged predicate write (this PR now)21 00083

Order is load-bearing, not cosmetic. Paging unconditionally measured 13x slower on the sizes every real install has: an id IN (...) page is evaluated by InMemoryDriver as a linear scan of the id list PER ROW (memory-matcher.ts, target.includes(value)), so an always-paged claim is quadratic there where the natural predicate is linear — 5 000 rows: 528 ms whole-set versus 5 865 ms always-paged, same engine, same driver, same row set. The page is what the engine's refusal buys, not the default.

CLAIM_PAGE_ROWS is derived from the ceiling rather than chosen (half of it), so it moves with the contract: the margin covers a driver's own bound-parameter limit on the id IN (...) list, and half the ceiling is still far above plugin-sharing's 1 000-row recompute cap, so a full page is still seen as a batch by the trailing-batch branch rather than recomputed row by row.

Termination does not rest on the page counter: a page that re-owns rows makes them stop matching the predicate, so the remainder strictly shrinks. MAX_CLAIM_PAGES is the belt for the one shape that reasoning does not cover — a driver reporting an affected count for rows it did not write — and hitting it is warned loudly, never silent. Three separate stop-and-warn paths cover an unreadable affected count, a page that matches rows but re-owns none, and a write that refuses for any code other than the declared per-row-hook budget.

#14719 was filed as the home for this work if paging turned out not to reach it. It did reach it — the number above is 21 000 of 21 000 — so that card is now a PM-seat judgement on this measurement, and this PR does not touch it.

⛔ Raising or exempting MAX_BULK_PER_ROW_HOOK_ROWS was refused: it is imported from @objectstack/spec/data and merely re-exported at engine.ts:7984, so moving it is a packages/spec contract change and the domain:spec seat's alone.

Premise re-verification (the card and triage read 4a37870; main has moved)

reading on origin/mainc616c2cc2
P1 single-id loop still thereclaim-seed-ownership.ts:121-128, the shape quoted verbatim in triage
P2#13533's isSystem removal landed?not landed — PR #14528 is state: open, merged: false; rule-hooks.ts still carries the skip in stashAffectedRows, afterInsert, afterUpdate. Both worlds measured above
P3 cap / trailing-batch machinery intactRULE_RECOMPUTE_ROW_CAP = 1_000; revokeThenQueueRegrant reaches service.evaluateAllRulesForObject(objectName) via ruleRegrantQueue
P4 the three call sites⚠️ partly drifted — see below

P4, precisely.bootstrap-platform-admin.ts:623 (not :533) calls it, via security-plugin.ts:3330. meta resync reaches it indirectly, through bootstrapPlatformAdmin(ql, sets, { resync: true }) at resync.ts:192 — that file names no claimSeedOwnership of its own. And ensure-default-organization.ts:406 is not a call site of this function: it invokes an injectedoptions.claimSeedOwnership with a different signature (ql, organizationId, userId, options), supplied by the enterprise organizations package; auth-plugin.ts:1100 passes { logger } only, so on this repo that hook is never wired.

Both remaining entries also short-circuit at already_have_admin (bootstrap-platform-admin.ts:412) before the claim, so the loop is reachable only while the install has no platform admin. That narrows the exposure the card described — it does not change the fix.

Tests

All figures below are from the final commit 5808133df unless stated.

pnpm --filter @objectstack/plugin-security testTest Files 95 passed (95) · Tests 1795 passed (1795), exit 0.
pnpm --filter @objectstack/plugin-security typecheck — exit 0, verdict line check:test-typecheck: OK — @objectstack/plugin-security's test layer compiles under packages/plugins/plugin-security/tsconfig.test.json; tsc -p tsconfig.test.json --listFiles confirms both edited files are in the program (1 hit each), so that verdict really covers them.
npx eslint . --no-inline-config (the repo-wide scan, not a narrowing) — exit 0, no output, 100 s.
pnpm check:nul-bytescheck-nul-bytes: OK (scanned 8047 text file(s) -- 8047 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).

Gate family re-derived on the final commit with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (44 commands; the derivation's stderr names this repo and commit 5808133df, with no stale-tree warning) and run: 39 green, 5 NOT MEASURED — each reported as such by its own verdict line, never read as green or red:

  • check-test-completeness — exit 3, PREREQUISITE NOT MET — this gate grades a saved 'turbo run test' log, and no log was named.
  • check:dual-build-cjs-loads — exit 3, Run 'pnpm build' first. ⛔ This is NOT a pass: nothing was measured.
  • check:type-check-debt — exit 3, check-type-check-coverage: PREREQUISITE NOT MET
  • check:i18n — exit 1, check-i18n-bundles: PREREQUISITE NOT MET — the workspace CLI is not built
  • scripts/pm/check-half-states.mjs — hit its 300 s bound having printed only re-exec with --use-env-proxy: HTTPS_PROXY is set and node's fetch does not read it; it is a PM tool that reaches GitHub, not a tree gate.

CI runs all five after a full build.

The two gates that were red, and their final verdict lines

check:engine-double-contract was x RETAINED [update]: claim-seed-ownership.test.ts now pins 5 engine double(s), ledger records 1. The gate's own prescribed fix is to ratchet, since coverage grew in the direction the ledger wants. scripts/engine-double-contract.pinned.json is contended by sibling PRs, so origin/main was merged and committed first and the regeneration ran on the merged tree — never hand-edited. Regeneration reported 1 added or grown, 0 lost; the diff is one field, pinned: 1 to pinned: 5. Final verdict line:

check-engine-double-contract: OK — 758 pinned, 134 in the DEBT ledger, 3 exempt.

check-tenant-audit-census is green on both legs, and the self-test: 5 of 19 half was diagnosed rather than assumed:

✓ check-tenant-audit-census: OK -- 217 write call sites certified (145 decidable; 9 tenancy-enabled sites
PROVABLY carry no tenant context, 32 more unreadable), 23 prose figures held to the census.
✓ check-tenant-audit-census self-test: 19 cases pass (...)

The five failures reproduce locally at 39126dc02 and at no other commit measured. They are one root cause, not five: all five are the live-tree family, which asserts the committed census artefacts still match the tree, and at 39126dc02 the tree had drifted (objectName | undecidable where the committed row says schema.name, and the corpus-scale count 15 to 16). They were caused by this PR's diff at that commit and were already answered at 0418ccf8f by binding the engine calls at the schema.name call site, so the census's answer about this file is what it was before the paging fallback existed, with no ledger row degraded to buy a green gate.

Measured, never inferred — the self-test at each commit:

treeself-test
39126dc02 (this branch, before the call-site binding)✗ 5 of 19 failed
0418ccf8f (this branch, after it)✓ 19 pass
75adf11da (origin/main at this branch's base)✓ 19 pass
af44044a8 (origin/main, mid-range)✓ 19 pass
4d0d9445a (origin/main, current then)✓ 19 pass

So it was never red on origin/main, and it is not red now. node scripts/tenant-audit-census.mjs --write on the merged tree rewrites only the explicitly non-enforced corpus-scale block and its measurement date (tracked non-test sources scanned 534 to 539), which moved because main grew files — not because of this diff. That rewrite was reverted rather than committed: the gate is green with the committed values, and the block's own prose says those figures are required to be present and dated, and their values are not compared.

Ablation — three legs, all red, all provably restored

The subject is imported relatively (import { claimSeedOwnership } from './claim-seed-ownership.js'), so vitest compiles the source directly; no dist/ sits in the resolution path, which is the condition scripts/ablation-dist-preflight.mjs states for its own hazard ("any test whose subject resolves through the dependency's exports"). Each leg proves the mutation landed on disk before its colour is read — a globalThis marker (never a comment, which esbuild strips), the removed anchor text counted to 0, and the blob hash moved off HEAD's — and proves the restore landed after — blob hash back to HEAD's d5512db31c4b66c1b0080ec0227c6141ada7d486 and git diff HEAD -- PATH empty. The restore is git checkout HEAD -- ABSOLUTE_PATH, never the bare form, and the driver carries trap restore EXIT INT TERM.

legmutationon-disk proofresult
C — paging removed (new)the per-row-hook refusal rethrows instead of pagingmarker 1, removed anchor 0, hash 4319c4ab... off HEADTests 3 failed / 14 passed (17)
A — back to the pre-#14530 single-id loopreown reads ids and issues one by-id update eachmarker 1, removed anchor 0, hash 2851cc3e...Tests 11 failed / 6 passed (17)
B — matched set narrowed{ owner_id: SystemUserId.SYSTEM } dropped from UNOWNED_PREDICATESmarker 1, removed anchor 0, hash 08a8256b...Tests 10 failed / 7 passed (17)

Leg C is the one that judges patch round 1, and its red set is exactly the paging pins — nothing else moves:

× claims EVERY unowned row past MAX_BULK_PER_ROW_HOOK_ROWS, where one unpaged write is refused whole
× count is the SUM over every write of the pass, not just the last one
× stops rather than spinning when a fallback page matches rows but re-owns none

Legs A and B are the earlier rounds' legs, re-run on the paged implementation to confirm they were not carried off by it. Both still redden the equivalence and shape pins:

× re-owns NULL and usr_system rows to the admin, leaving human-owned rows untouched
× issues ONE predicate write per unowned shape — never one write per row
× claims exactly the id set the pre-#14530 single-id loop would have claimed
× the two predicates stay disjoint — no row is counted twice
× reports the affected-row count the write resolved, not a length it counted itself
× says "unknown" rather than 0 when a driver resolves something that is not a count
× a refused predicate write costs that predicate only — never the object or the run

The equivalence pin re-states the OLD rule (two scans at limit: 10_000, deduped) rather than calling into the implementation under test, on a fixture that includes an absent column, a present-but-undefined, an empty string and a usr_system_admin prefix collision, and asserts the new write claims that id set exactly. The over-ceiling pin compares against the uncapped unowned set instead, because past 10 000 the old rule was itself lossy and the new one must beat it, not match it.

Clause ②: no

Re-derived from the current diff on the final commit:

$ git diff -U0 origin/main...HEAD | grep -E '^\+\s*export '
$ echo $?
1

No new exported symbol. grep export cannot see a changed signature on an already-exported declaration, so that was checked separately: claimSeedOwnership is the file's only export, and its signature is byte-identical to origin/main's — (ql: any, adminUserId: string, options: ClaimOwnershipOptions = {}): Promise SUMMARY_ARRAY, where the summary element type { object: string; count: number } is also unchanged. CLAIM_PAGE_ROWS, MAX_CLAIM_PAGES, UNOWNED_PREDICATES, ObjectWriter, claimPredicate, isPerRowHookBudgetRefusal, affectedRowCount and idsFrom are all module-private. No carrier is owed.

One accept-set note that is not an export: the typeof ql.find !== 'function' precondition is retained, because the paged fallback reads.

Not done, deliberately

Disposition 3 (a boot-phase predicate) is out of scope and not this seat's to authorise; the hooks are not made to coalesce across writes; packages/spec is not touched.

⚠️packages/cli's run-dev-unbuilt-workspace.e2e.test.ts is a known repo-wide flake (#14648 / #14727, domain:cli is on it). It is unrelated to this branch and is neither fixed nor skipped here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

os-salesand others added 4 commits September 2, 2026 19:25
…per unowned shape
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
`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
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

10 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

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 dbf115284295b1989d4648dbfbd7e5f3f96357dcpackageMentionDocs.

Which tree this was computed on

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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI red on 9f17ab8a0 — the tracked run-dev-unbuilt-workspace flake, not this PR's

Test Core (1/6), read from the job log:

::error file=packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts,
line=317: AssertionError: expected 'SIGKILL' to be null
Duration 945.51s (import 373.97s, tests 2420.53s)

Not this PR's. The diff is three files, all in @objectstack/plugin-security (claim-seed-ownership.ts, its test, one changeset) — it touches nothing in @objectstack/cli, nothing the CLI boots, and no shared script that test loads. The failing test is the tracked flake anchored at #14648 (priority:p1, domain:cli, dispatched, dev on it since 18:15Z), and the asserted value is the harness's own kill signal firing at its 40 s cap — a cap derived from a measured ~22 s uncontended run, against a shard that here spent 2420 s of test time. A stopwatch, not a behaviour.

This is the fourth PR from this seat that the same signature has taken (#14528 and #14687 in the merge queue, #14712 and now this one on branch CI).

No re-run spent and nothing pushed for it. Patch round 1 is in flight on this branch — the seat's review is at 14530#issuecomment-5516113880 and requires the predicate write to be paged so objects above MAX_BULK_PER_ROW_HOOK_ROWS are still claimed — so the head moves shortly and CI re-runs on its own.

@objectstack/plugin-security's own suite was green on this head: Test Files 95 passed (95) · Tests 1791 passed (1791).


Generated by Claude Code

…cts 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
…ema.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
@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Blocker record — this PR's own gates, and what is queued behind them

Putting this on the PR because the seat's instructions to the dev agent went out over a channel that did not survive: the container running this session restarted at ~21:45Z and killed the agent working on this branch. Nothing was lost from the branch (its head 0418ccf8f was already pushed), but the in-flight instructions died with the agent, so they are restated here where they persist.

Gate 1 — check-tenant-audit-census, red at 39126dc02, addressed at 0418ccf8f

census : | packages/plugins/plugin-security/src/claim-seed-ownership.ts | update | objectName | undecidable | elevated | 1 |
Fix: node scripts/tenant-audit-census.mjs --write
✗ check-tenant-audit-census self-test: 5 of 19 case(s) failed.

The seat's ruling on it, restated: ⛔ do not run --write and call it done.objectName going undecidable is a signal, not a chore — before the paging change that write site's object name was statically decidable, and recording the downgrade trades the census's proving power for a green light. The right first question is whether the write site can stay decidable. The commit 0418ccf8f ("bind the seed-ownership engine calls at the schema.name call site") is exactly that answer, and it is the right one.

The self-test: 5 of 19 half is a separate question and must not be assumed to travel with it: read the full job output, and for each of the five decide whether this diff falsified its premise or whether it is red on origin/main too — that has to be measured on origin/main, never inferred. If it is red there, it is not this PR's.

⚠️ Note the gate's own words: "selfTest() returned without reaching its verdict, so no success line was printed." By this repo's standing rule, no verdict line means it did not pass — it cannot be read as green.

Gate 2 — check-engine-double-contract, red now at 0418ccf8f

x RETAINED [update]: packages/plugins/plugin-security/src/claim-seed-ownership.test.ts now pins 5 engine double(s), ledger records 1.
Coverage grew, which is the direction this ledger wants — run
`node scripts/check-engine-double-contract.mjs --write` and commit so the new double is ratcheted too.
check-engine-double-contract: 1 problem(s).

This one is the mechanical case: the gate says coverage grew in the direction the ledger wants, and ratcheting is the prescribed fix. Unlike gate 1, there is no downgrade being recorded.

⚠️But scripts/engine-double-contract.pinned.json is three-way contended right now — this PR, #14528 and #14726 all write it. So the ratchet is not a standalone --write: merge origin/main first, commit the merge, then regenerate on the merged tree and let check:engine-double-contract be the proof. ⛔ Never hand-edit the ledger.

(The two unrecognised [findOne] lines name other files and are not counted in the gate's single problem; they need no action here.)

What is not blocked

The paging work itself measured well and is not in question: 5000 rows went from 10 658 ms / 5000 engine writes to 448 ms / 2 writes, the cap / trailing-batch branch was measured to engage over-cap with zero change to plugin-sharing, and the P2 premise came back false and was handled by measuring both worlds with the per-object skip notice as the discriminator. The seat's review is at 14530#issuecomment-5516113880; the one substantive change still owed there is paging the predicate write so objects above MAX_BULK_PER_ROW_HOOK_ROWS are claimed rather than refused whole.

Queue position, stated so the wait is a record

This seat is at the maintainer's dispatch cap of 3 running dev agents (two post-restart recoveries plus PR #14528's merge round). This PR's remaining work is next after PR #14726's patch round. ⛔ No manual re-run is being spent on the run-dev-unbuilt-workspace flake that also hits this branch — that is #14648's, and it is already dispatched in domain:cli.


Generated by Claude Code

…ership 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
… 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
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.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf(plugin-security): claim seed ownership with one predicate write per unowned shape (#14530) - #14718

Merged
os-sales merged 11 commits into
mainfrom
claude/issue-14530-claim-seed-ownership-predicate-write
Sep 3, 2026
Merged

perf(plugin-security): claim seed ownership with one predicate write per unowned shape (#14530)#14718
os-sales merged 11 commits into
mainfrom
claude/issue-14530-claim-seed-ownership-predicate-write

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14530

Triage ruled disposition 2 (14530#issuecomment-5508899386): turn claimSeedOwnership's single-id loop into a predicate write per object, take the missing latency measurement first, and confirm at step 3 that the cap / trailing-batch branch in rule-hooks.ts actually engages on the new write shape. All three steps are below, with numbers.

Seat review 14530#issuecomment-5516113880 then ruled patch round 1 into this PR rather than deferring it: an over-ceiling predicate write is refused whole, which took an object above the ceiling from partially claimed to not claimed at all. That is fixed here, and measured. plugin-sharing is untouched throughout — that the fix needs no change there is the whole argument for disposition 2.

The change

claimSeedOwnership scanned every owner_id-declaring object twice at limit: 10_000 and then issued one single-idupdate per matched id — up to 20 000 full engine writes for one object. It now issues one predicate write per unowned shape, and pages that write only when the engine refuses it:

for(constwhereof[{owner_id: null},{owner_id: SystemUserId.SYSTEM}]){constaffected=awaitql.update(name,{owner_id: adminUserId},{ where,multi: true,context: SYSTEM_CTX});}

Two writes per object on every install small enough for them, which in practice is all of them. The scans are gone with the loop: the predicates they resolved are the predicates the writes now carry, so the matched set is unchanged row for row, and the per-object count is the sum of the affected-row counts the writes themselves resolve (#4639) rather than a length this function counted.

The two predicates stay two narrow writes rather than one OR/IN for the reason the scans were two — driver portability — and they stay disjoint in this order, because the NULL write lands adminUserId, which can never be usr_system (that target is refused at the top of the function).

Step 1 — the latency the card said was missing

The card is explicit that there is no number. Here are two, before and after, on a real ObjectQL engine.

Method.new ObjectQL() on InMemoryDriver (persistence: false), one object crm_lead declaring id/name/owner_id, N rows seeded straight through the driver (so setup pays no hook cost) alternating owner_id: null and owner_id: 'usr_system'. plugin-sharing's realbindRuleHooks is bound over a counting SharingRuleService double with one active rule on that object, so every branch of rule-hooks.ts the write shape can reach is counted. The window is claimSeedOwnership itself, process.hrtime.bigint() either side, plus a drain of ruleRegrantQueue. engine.update is wrapped to count the caller's writes. The "before" shape is the pre-change body inlined verbatim from origin/mainc616c2cc2; the "after" shape is imported from the built package.

⚠️Shared box. These are absolutes measured on a container running other agents' builds concurrently, so read the ratios, not the wall clock.

Baseline A — origin/main (c616c2cc2), i.e. before#13533

rowsshapeclaim msengine update callsevaluateAllForRecordskip notices
200before125.120001
200after18.6201
2 000before2 122.62 00001
2 000after170.5201
5 000before10 658.05 00001
5 000after448.0201

6.7x / 12.4x / 23.8x, and the write count stops scaling with N. evaluateAllForRecord: 0 and one skip notice per object are the signature of the pre-#13533 world: bindRuleHooks still opens afterInsert/afterUpdate with if (ctx?.session?.isSystem) { noteSystemWriteSkipped(); return; }, so no sharing materialisation runs at all on this path yet.

Baseline B — PR #14528 head (e9b612a7a), i.e. after#13533

Same harness, same object, run in a separate throwaway worktree at that PR's head with this branch's claim-seed-ownership.ts copied in and rebuilt.

rowsshapeclaim msengine update callsevaluateAllForRecordrevokeRuleGrantsForObjectevaluateAllRulesForObjectskip notices
800before490.0800800000
800after58.32800000
5 000before11 900.65 0005 000000
5 000after489.520220

8.4x and 24.3x. skipNotices: 0 is the discriminator that says these rows were measured in the post-#13533 world.

Step 2 to Step 3 — the cap / trailing-batch branch DOES engage

This is the stop-and-report condition, and it passes — conditionally on #13533, which is the honest reading:

  • 800 rows (400 per predicate, at or under RULE_RECOMPUTE_ROW_CAP = 1 000): the bounded branch runs — evaluateAllForRecord = 800, revokeRuleGrantsForObject = 0. Same per-record grant work as before, from 2 engine writes instead of 800.
  • 5 000 rows (2 500 per predicate, over the cap): readAffectedRows returns over-cap, afterUpdate takes revokeThenQueueRegrant, and the counters read evaluateAllForRecord = 0, revokeRuleGrantsForObject = 2, evaluateAllRulesForObject = 2 — one set-based revoke plus one queued full reconcile per predicate write. That is exactly the branch triage asked to see engaged, reached with no change to plugin-sharing.
  • The old shape never reaches it at any N: each write's row set is one row, affected.kind === 'rows' with a single id, and the cap can never fire. revokeRuleGrantsForObject = 0 at 5 000 rows confirms it.

On today's origin/main the branch is NOT reached#13533's PR #14528 is still open, so the isSystem skip in afterUpdate returns before affectedFrom(ctx) is ever consulted. The write-count and latency win is real and complete today; the cap-branch half arrives with #13533 and is measured above at that PR's head.

Patch round 1 — paging past the engine's per-row hook ceiling

A predicate write carries no limit, so the bound is the engine's own MAX_BULK_PER_ROW_HOOK_ROWS (10 000): beforeUpdate / afterUpdate hooks are contracted to fire per matched row on a predicate write (ADR-0058 D6), and every object carries such hooks in practice, so an over-sized write is refused whole — nothing written. owner_id is a record-access field, so "the object was not claimed" is a permission outcome, not an observability detail.

The refusal is a declared, total verdict whose own message names pagination as the remedy, so it is answered: take one page of ids off the top (CLAIM_PAGE_ROWS, half the ceiling) and re-attempt the whole set. Each page shrinks what is left until one write can carry it, and the pass ends on a whole-set write rather than on a count of pages.

Measured on the same 21 000-row object, one shape per row:

shaperows claimed of 21 000engine writesengine reads
pre-#14530 single-id loop (scans capped at limit: 10_000)10 00010 0002
unpaged predicate write (this PR before patch round 1)020
paged predicate write (this PR now)21 00083

Order is load-bearing, not cosmetic. Paging unconditionally measured 13x slower on the sizes every real install has: an id IN (...) page is evaluated by InMemoryDriver as a linear scan of the id list PER ROW (memory-matcher.ts, target.includes(value)), so an always-paged claim is quadratic there where the natural predicate is linear — 5 000 rows: 528 ms whole-set versus 5 865 ms always-paged, same engine, same driver, same row set. The page is what the engine's refusal buys, not the default.

CLAIM_PAGE_ROWS is derived from the ceiling rather than chosen (half of it), so it moves with the contract: the margin covers a driver's own bound-parameter limit on the id IN (...) list, and half the ceiling is still far above plugin-sharing's 1 000-row recompute cap, so a full page is still seen as a batch by the trailing-batch branch rather than recomputed row by row.

Termination does not rest on the page counter: a page that re-owns rows makes them stop matching the predicate, so the remainder strictly shrinks. MAX_CLAIM_PAGES is the belt for the one shape that reasoning does not cover — a driver reporting an affected count for rows it did not write — and hitting it is warned loudly, never silent. Three separate stop-and-warn paths cover an unreadable affected count, a page that matches rows but re-owns none, and a write that refuses for any code other than the declared per-row-hook budget.

#14719 was filed as the home for this work if paging turned out not to reach it. It did reach it — the number above is 21 000 of 21 000 — so that card is now a PM-seat judgement on this measurement, and this PR does not touch it.

⛔ Raising or exempting MAX_BULK_PER_ROW_HOOK_ROWS was refused: it is imported from @objectstack/spec/data and merely re-exported at engine.ts:7984, so moving it is a packages/spec contract change and the domain:spec seat's alone.

Premise re-verification (the card and triage read 4a37870; main has moved)

reading on origin/mainc616c2cc2
P1 single-id loop still thereclaim-seed-ownership.ts:121-128, the shape quoted verbatim in triage
P2#13533's isSystem removal landed?not landed — PR #14528 is state: open, merged: false; rule-hooks.ts still carries the skip in stashAffectedRows, afterInsert, afterUpdate. Both worlds measured above
P3 cap / trailing-batch machinery intactRULE_RECOMPUTE_ROW_CAP = 1_000; revokeThenQueueRegrant reaches service.evaluateAllRulesForObject(objectName) via ruleRegrantQueue
P4 the three call sites⚠️ partly drifted — see below

P4, precisely.bootstrap-platform-admin.ts:623 (not :533) calls it, via security-plugin.ts:3330. meta resync reaches it indirectly, through bootstrapPlatformAdmin(ql, sets, { resync: true }) at resync.ts:192 — that file names no claimSeedOwnership of its own. And ensure-default-organization.ts:406 is not a call site of this function: it invokes an injectedoptions.claimSeedOwnership with a different signature (ql, organizationId, userId, options), supplied by the enterprise organizations package; auth-plugin.ts:1100 passes { logger } only, so on this repo that hook is never wired.

Both remaining entries also short-circuit at already_have_admin (bootstrap-platform-admin.ts:412) before the claim, so the loop is reachable only while the install has no platform admin. That narrows the exposure the card described — it does not change the fix.

Tests

All figures below are from the final commit 5808133df unless stated.

pnpm --filter @objectstack/plugin-security testTest Files 95 passed (95) · Tests 1795 passed (1795), exit 0.
pnpm --filter @objectstack/plugin-security typecheck — exit 0, verdict line check:test-typecheck: OK — @objectstack/plugin-security's test layer compiles under packages/plugins/plugin-security/tsconfig.test.json; tsc -p tsconfig.test.json --listFiles confirms both edited files are in the program (1 hit each), so that verdict really covers them.
npx eslint . --no-inline-config (the repo-wide scan, not a narrowing) — exit 0, no output, 100 s.
pnpm check:nul-bytescheck-nul-bytes: OK (scanned 8047 text file(s) -- 8047 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).

Gate family re-derived on the final commit with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (44 commands; the derivation's stderr names this repo and commit 5808133df, with no stale-tree warning) and run: 39 green, 5 NOT MEASURED — each reported as such by its own verdict line, never read as green or red:

  • check-test-completeness — exit 3, PREREQUISITE NOT MET — this gate grades a saved 'turbo run test' log, and no log was named.
  • check:dual-build-cjs-loads — exit 3, Run 'pnpm build' first. ⛔ This is NOT a pass: nothing was measured.
  • check:type-check-debt — exit 3, check-type-check-coverage: PREREQUISITE NOT MET
  • check:i18n — exit 1, check-i18n-bundles: PREREQUISITE NOT MET — the workspace CLI is not built
  • scripts/pm/check-half-states.mjs — hit its 300 s bound having printed only re-exec with --use-env-proxy: HTTPS_PROXY is set and node's fetch does not read it; it is a PM tool that reaches GitHub, not a tree gate.

CI runs all five after a full build.

The two gates that were red, and their final verdict lines

check:engine-double-contract was x RETAINED [update]: claim-seed-ownership.test.ts now pins 5 engine double(s), ledger records 1. The gate's own prescribed fix is to ratchet, since coverage grew in the direction the ledger wants. scripts/engine-double-contract.pinned.json is contended by sibling PRs, so origin/main was merged and committed first and the regeneration ran on the merged tree — never hand-edited. Regeneration reported 1 added or grown, 0 lost; the diff is one field, pinned: 1 to pinned: 5. Final verdict line:

check-engine-double-contract: OK — 758 pinned, 134 in the DEBT ledger, 3 exempt.

check-tenant-audit-census is green on both legs, and the self-test: 5 of 19 half was diagnosed rather than assumed:

✓ check-tenant-audit-census: OK -- 217 write call sites certified (145 decidable; 9 tenancy-enabled sites
PROVABLY carry no tenant context, 32 more unreadable), 23 prose figures held to the census.
✓ check-tenant-audit-census self-test: 19 cases pass (...)

The five failures reproduce locally at 39126dc02 and at no other commit measured. They are one root cause, not five: all five are the live-tree family, which asserts the committed census artefacts still match the tree, and at 39126dc02 the tree had drifted (objectName | undecidable where the committed row says schema.name, and the corpus-scale count 15 to 16). They were caused by this PR's diff at that commit and were already answered at 0418ccf8f by binding the engine calls at the schema.name call site, so the census's answer about this file is what it was before the paging fallback existed, with no ledger row degraded to buy a green gate.

Measured, never inferred — the self-test at each commit:

treeself-test
39126dc02 (this branch, before the call-site binding)✗ 5 of 19 failed
0418ccf8f (this branch, after it)✓ 19 pass
75adf11da (origin/main at this branch's base)✓ 19 pass
af44044a8 (origin/main, mid-range)✓ 19 pass
4d0d9445a (origin/main, current then)✓ 19 pass

So it was never red on origin/main, and it is not red now. node scripts/tenant-audit-census.mjs --write on the merged tree rewrites only the explicitly non-enforced corpus-scale block and its measurement date (tracked non-test sources scanned 534 to 539), which moved because main grew files — not because of this diff. That rewrite was reverted rather than committed: the gate is green with the committed values, and the block's own prose says those figures are required to be present and dated, and their values are not compared.

Ablation — three legs, all red, all provably restored

The subject is imported relatively (import { claimSeedOwnership } from './claim-seed-ownership.js'), so vitest compiles the source directly; no dist/ sits in the resolution path, which is the condition scripts/ablation-dist-preflight.mjs states for its own hazard ("any test whose subject resolves through the dependency's exports"). Each leg proves the mutation landed on disk before its colour is read — a globalThis marker (never a comment, which esbuild strips), the removed anchor text counted to 0, and the blob hash moved off HEAD's — and proves the restore landed after — blob hash back to HEAD's d5512db31c4b66c1b0080ec0227c6141ada7d486 and git diff HEAD -- PATH empty. The restore is git checkout HEAD -- ABSOLUTE_PATH, never the bare form, and the driver carries trap restore EXIT INT TERM.

legmutationon-disk proofresult
C — paging removed (new)the per-row-hook refusal rethrows instead of pagingmarker 1, removed anchor 0, hash 4319c4ab... off HEADTests 3 failed / 14 passed (17)
A — back to the pre-#14530 single-id loopreown reads ids and issues one by-id update eachmarker 1, removed anchor 0, hash 2851cc3e...Tests 11 failed / 6 passed (17)
B — matched set narrowed{ owner_id: SystemUserId.SYSTEM } dropped from UNOWNED_PREDICATESmarker 1, removed anchor 0, hash 08a8256b...Tests 10 failed / 7 passed (17)

Leg C is the one that judges patch round 1, and its red set is exactly the paging pins — nothing else moves:

× claims EVERY unowned row past MAX_BULK_PER_ROW_HOOK_ROWS, where one unpaged write is refused whole
× count is the SUM over every write of the pass, not just the last one
× stops rather than spinning when a fallback page matches rows but re-owns none

Legs A and B are the earlier rounds' legs, re-run on the paged implementation to confirm they were not carried off by it. Both still redden the equivalence and shape pins:

× re-owns NULL and usr_system rows to the admin, leaving human-owned rows untouched
× issues ONE predicate write per unowned shape — never one write per row
× claims exactly the id set the pre-#14530 single-id loop would have claimed
× the two predicates stay disjoint — no row is counted twice
× reports the affected-row count the write resolved, not a length it counted itself
× says "unknown" rather than 0 when a driver resolves something that is not a count
× a refused predicate write costs that predicate only — never the object or the run

The equivalence pin re-states the OLD rule (two scans at limit: 10_000, deduped) rather than calling into the implementation under test, on a fixture that includes an absent column, a present-but-undefined, an empty string and a usr_system_admin prefix collision, and asserts the new write claims that id set exactly. The over-ceiling pin compares against the uncapped unowned set instead, because past 10 000 the old rule was itself lossy and the new one must beat it, not match it.

Clause ②: no

Re-derived from the current diff on the final commit:

$ git diff -U0 origin/main...HEAD | grep -E '^\+\s*export '
$ echo $?
1

No new exported symbol. grep export cannot see a changed signature on an already-exported declaration, so that was checked separately: claimSeedOwnership is the file's only export, and its signature is byte-identical to origin/main's — (ql: any, adminUserId: string, options: ClaimOwnershipOptions = {}): Promise SUMMARY_ARRAY, where the summary element type { object: string; count: number } is also unchanged. CLAIM_PAGE_ROWS, MAX_CLAIM_PAGES, UNOWNED_PREDICATES, ObjectWriter, claimPredicate, isPerRowHookBudgetRefusal, affectedRowCount and idsFrom are all module-private. No carrier is owed.

One accept-set note that is not an export: the typeof ql.find !== 'function' precondition is retained, because the paged fallback reads.

Not done, deliberately

Disposition 3 (a boot-phase predicate) is out of scope and not this seat's to authorise; the hooks are not made to coalesce across writes; packages/spec is not touched.

⚠️packages/cli's run-dev-unbuilt-workspace.e2e.test.ts is a known repo-wide flake (#14648 / #14727, domain:cli is on it). It is unrelated to this branch and is neither fixed nor skipped here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

os-salesand others added 4 commits September 2, 2026 19:25
…per unowned shape
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
`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
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

10 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

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 dbf115284295b1989d4648dbfbd7e5f3f96357dcpackageMentionDocs.

Which tree this was computed on

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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI red on 9f17ab8a0 — the tracked run-dev-unbuilt-workspace flake, not this PR's

Test Core (1/6), read from the job log:

::error file=packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts,
line=317: AssertionError: expected 'SIGKILL' to be null
Duration 945.51s (import 373.97s, tests 2420.53s)

Not this PR's. The diff is three files, all in @objectstack/plugin-security (claim-seed-ownership.ts, its test, one changeset) — it touches nothing in @objectstack/cli, nothing the CLI boots, and no shared script that test loads. The failing test is the tracked flake anchored at #14648 (priority:p1, domain:cli, dispatched, dev on it since 18:15Z), and the asserted value is the harness's own kill signal firing at its 40 s cap — a cap derived from a measured ~22 s uncontended run, against a shard that here spent 2420 s of test time. A stopwatch, not a behaviour.

This is the fourth PR from this seat that the same signature has taken (#14528 and #14687 in the merge queue, #14712 and now this one on branch CI).

No re-run spent and nothing pushed for it. Patch round 1 is in flight on this branch — the seat's review is at 14530#issuecomment-5516113880 and requires the predicate write to be paged so objects above MAX_BULK_PER_ROW_HOOK_ROWS are still claimed — so the head moves shortly and CI re-runs on its own.

@objectstack/plugin-security's own suite was green on this head: Test Files 95 passed (95) · Tests 1791 passed (1791).


Generated by Claude Code

…cts 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
…ema.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
@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Blocker record — this PR's own gates, and what is queued behind them

Putting this on the PR because the seat's instructions to the dev agent went out over a channel that did not survive: the container running this session restarted at ~21:45Z and killed the agent working on this branch. Nothing was lost from the branch (its head 0418ccf8f was already pushed), but the in-flight instructions died with the agent, so they are restated here where they persist.

Gate 1 — check-tenant-audit-census, red at 39126dc02, addressed at 0418ccf8f

census : | packages/plugins/plugin-security/src/claim-seed-ownership.ts | update | objectName | undecidable | elevated | 1 |
Fix: node scripts/tenant-audit-census.mjs --write
✗ check-tenant-audit-census self-test: 5 of 19 case(s) failed.

The seat's ruling on it, restated: ⛔ do not run --write and call it done.objectName going undecidable is a signal, not a chore — before the paging change that write site's object name was statically decidable, and recording the downgrade trades the census's proving power for a green light. The right first question is whether the write site can stay decidable. The commit 0418ccf8f ("bind the seed-ownership engine calls at the schema.name call site") is exactly that answer, and it is the right one.

The self-test: 5 of 19 half is a separate question and must not be assumed to travel with it: read the full job output, and for each of the five decide whether this diff falsified its premise or whether it is red on origin/main too — that has to be measured on origin/main, never inferred. If it is red there, it is not this PR's.

⚠️ Note the gate's own words: "selfTest() returned without reaching its verdict, so no success line was printed." By this repo's standing rule, no verdict line means it did not pass — it cannot be read as green.

Gate 2 — check-engine-double-contract, red now at 0418ccf8f

x RETAINED [update]: packages/plugins/plugin-security/src/claim-seed-ownership.test.ts now pins 5 engine double(s), ledger records 1.
Coverage grew, which is the direction this ledger wants — run
`node scripts/check-engine-double-contract.mjs --write` and commit so the new double is ratcheted too.
check-engine-double-contract: 1 problem(s).

This one is the mechanical case: the gate says coverage grew in the direction the ledger wants, and ratcheting is the prescribed fix. Unlike gate 1, there is no downgrade being recorded.

⚠️But scripts/engine-double-contract.pinned.json is three-way contended right now — this PR, #14528 and #14726 all write it. So the ratchet is not a standalone --write: merge origin/main first, commit the merge, then regenerate on the merged tree and let check:engine-double-contract be the proof. ⛔ Never hand-edit the ledger.

(The two unrecognised [findOne] lines name other files and are not counted in the gate's single problem; they need no action here.)

What is not blocked

The paging work itself measured well and is not in question: 5000 rows went from 10 658 ms / 5000 engine writes to 448 ms / 2 writes, the cap / trailing-batch branch was measured to engage over-cap with zero change to plugin-sharing, and the P2 premise came back false and was handled by measuring both worlds with the per-object skip notice as the discriminator. The seat's review is at 14530#issuecomment-5516113880; the one substantive change still owed there is paging the predicate write so objects above MAX_BULK_PER_ROW_HOOK_ROWS are claimed rather than refused whole.

Queue position, stated so the wait is a record

This seat is at the maintainer's dispatch cap of 3 running dev agents (two post-restart recoveries plus PR #14528's merge round). This PR's remaining work is next after PR #14726's patch round. ⛔ No manual re-run is being spent on the run-dev-unbuilt-workspace flake that also hits this branch — that is #14648's, and it is already dispatched in domain:cli.


Generated by Claude Code

…ership 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
… 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
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.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

perf(plugin-security): claim seed ownership with one predicate write per unowned shape (#14530) - #14718

Merged
os-sales merged 11 commits into
mainfrom
claude/issue-14530-claim-seed-ownership-predicate-write
Sep 3, 2026
Merged

perf(plugin-security): claim seed ownership with one predicate write per unowned shape (#14530)#14718
os-sales merged 11 commits into
mainfrom
claude/issue-14530-claim-seed-ownership-predicate-write

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14530

Triage ruled disposition 2 (14530#issuecomment-5508899386): turn claimSeedOwnership's single-id loop into a predicate write per object, take the missing latency measurement first, and confirm at step 3 that the cap / trailing-batch branch in rule-hooks.ts actually engages on the new write shape. All three steps are below, with numbers.

Seat review 14530#issuecomment-5516113880 then ruled patch round 1 into this PR rather than deferring it: an over-ceiling predicate write is refused whole, which took an object above the ceiling from partially claimed to not claimed at all. That is fixed here, and measured. plugin-sharing is untouched throughout — that the fix needs no change there is the whole argument for disposition 2.

The change

claimSeedOwnership scanned every owner_id-declaring object twice at limit: 10_000 and then issued one single-idupdate per matched id — up to 20 000 full engine writes for one object. It now issues one predicate write per unowned shape, and pages that write only when the engine refuses it:

for(constwhereof[{owner_id: null},{owner_id: SystemUserId.SYSTEM}]){constaffected=awaitql.update(name,{owner_id: adminUserId},{ where,multi: true,context: SYSTEM_CTX});}

Two writes per object on every install small enough for them, which in practice is all of them. The scans are gone with the loop: the predicates they resolved are the predicates the writes now carry, so the matched set is unchanged row for row, and the per-object count is the sum of the affected-row counts the writes themselves resolve (#4639) rather than a length this function counted.

The two predicates stay two narrow writes rather than one OR/IN for the reason the scans were two — driver portability — and they stay disjoint in this order, because the NULL write lands adminUserId, which can never be usr_system (that target is refused at the top of the function).

Step 1 — the latency the card said was missing

The card is explicit that there is no number. Here are two, before and after, on a real ObjectQL engine.

Method.new ObjectQL() on InMemoryDriver (persistence: false), one object crm_lead declaring id/name/owner_id, N rows seeded straight through the driver (so setup pays no hook cost) alternating owner_id: null and owner_id: 'usr_system'. plugin-sharing's realbindRuleHooks is bound over a counting SharingRuleService double with one active rule on that object, so every branch of rule-hooks.ts the write shape can reach is counted. The window is claimSeedOwnership itself, process.hrtime.bigint() either side, plus a drain of ruleRegrantQueue. engine.update is wrapped to count the caller's writes. The "before" shape is the pre-change body inlined verbatim from origin/mainc616c2cc2; the "after" shape is imported from the built package.

⚠️Shared box. These are absolutes measured on a container running other agents' builds concurrently, so read the ratios, not the wall clock.

Baseline A — origin/main (c616c2cc2), i.e. before#13533

rowsshapeclaim msengine update callsevaluateAllForRecordskip notices
200before125.120001
200after18.6201
2 000before2 122.62 00001
2 000after170.5201
5 000before10 658.05 00001
5 000after448.0201

6.7x / 12.4x / 23.8x, and the write count stops scaling with N. evaluateAllForRecord: 0 and one skip notice per object are the signature of the pre-#13533 world: bindRuleHooks still opens afterInsert/afterUpdate with if (ctx?.session?.isSystem) { noteSystemWriteSkipped(); return; }, so no sharing materialisation runs at all on this path yet.

Baseline B — PR #14528 head (e9b612a7a), i.e. after#13533

Same harness, same object, run in a separate throwaway worktree at that PR's head with this branch's claim-seed-ownership.ts copied in and rebuilt.

rowsshapeclaim msengine update callsevaluateAllForRecordrevokeRuleGrantsForObjectevaluateAllRulesForObjectskip notices
800before490.0800800000
800after58.32800000
5 000before11 900.65 0005 000000
5 000after489.520220

8.4x and 24.3x. skipNotices: 0 is the discriminator that says these rows were measured in the post-#13533 world.

Step 2 to Step 3 — the cap / trailing-batch branch DOES engage

This is the stop-and-report condition, and it passes — conditionally on #13533, which is the honest reading:

  • 800 rows (400 per predicate, at or under RULE_RECOMPUTE_ROW_CAP = 1 000): the bounded branch runs — evaluateAllForRecord = 800, revokeRuleGrantsForObject = 0. Same per-record grant work as before, from 2 engine writes instead of 800.
  • 5 000 rows (2 500 per predicate, over the cap): readAffectedRows returns over-cap, afterUpdate takes revokeThenQueueRegrant, and the counters read evaluateAllForRecord = 0, revokeRuleGrantsForObject = 2, evaluateAllRulesForObject = 2 — one set-based revoke plus one queued full reconcile per predicate write. That is exactly the branch triage asked to see engaged, reached with no change to plugin-sharing.
  • The old shape never reaches it at any N: each write's row set is one row, affected.kind === 'rows' with a single id, and the cap can never fire. revokeRuleGrantsForObject = 0 at 5 000 rows confirms it.

On today's origin/main the branch is NOT reached#13533's PR #14528 is still open, so the isSystem skip in afterUpdate returns before affectedFrom(ctx) is ever consulted. The write-count and latency win is real and complete today; the cap-branch half arrives with #13533 and is measured above at that PR's head.

Patch round 1 — paging past the engine's per-row hook ceiling

A predicate write carries no limit, so the bound is the engine's own MAX_BULK_PER_ROW_HOOK_ROWS (10 000): beforeUpdate / afterUpdate hooks are contracted to fire per matched row on a predicate write (ADR-0058 D6), and every object carries such hooks in practice, so an over-sized write is refused whole — nothing written. owner_id is a record-access field, so "the object was not claimed" is a permission outcome, not an observability detail.

The refusal is a declared, total verdict whose own message names pagination as the remedy, so it is answered: take one page of ids off the top (CLAIM_PAGE_ROWS, half the ceiling) and re-attempt the whole set. Each page shrinks what is left until one write can carry it, and the pass ends on a whole-set write rather than on a count of pages.

Measured on the same 21 000-row object, one shape per row:

shaperows claimed of 21 000engine writesengine reads
pre-#14530 single-id loop (scans capped at limit: 10_000)10 00010 0002
unpaged predicate write (this PR before patch round 1)020
paged predicate write (this PR now)21 00083

Order is load-bearing, not cosmetic. Paging unconditionally measured 13x slower on the sizes every real install has: an id IN (...) page is evaluated by InMemoryDriver as a linear scan of the id list PER ROW (memory-matcher.ts, target.includes(value)), so an always-paged claim is quadratic there where the natural predicate is linear — 5 000 rows: 528 ms whole-set versus 5 865 ms always-paged, same engine, same driver, same row set. The page is what the engine's refusal buys, not the default.

CLAIM_PAGE_ROWS is derived from the ceiling rather than chosen (half of it), so it moves with the contract: the margin covers a driver's own bound-parameter limit on the id IN (...) list, and half the ceiling is still far above plugin-sharing's 1 000-row recompute cap, so a full page is still seen as a batch by the trailing-batch branch rather than recomputed row by row.

Termination does not rest on the page counter: a page that re-owns rows makes them stop matching the predicate, so the remainder strictly shrinks. MAX_CLAIM_PAGES is the belt for the one shape that reasoning does not cover — a driver reporting an affected count for rows it did not write — and hitting it is warned loudly, never silent. Three separate stop-and-warn paths cover an unreadable affected count, a page that matches rows but re-owns none, and a write that refuses for any code other than the declared per-row-hook budget.

#14719 was filed as the home for this work if paging turned out not to reach it. It did reach it — the number above is 21 000 of 21 000 — so that card is now a PM-seat judgement on this measurement, and this PR does not touch it.

⛔ Raising or exempting MAX_BULK_PER_ROW_HOOK_ROWS was refused: it is imported from @objectstack/spec/data and merely re-exported at engine.ts:7984, so moving it is a packages/spec contract change and the domain:spec seat's alone.

Premise re-verification (the card and triage read 4a37870; main has moved)

reading on origin/mainc616c2cc2
P1 single-id loop still thereclaim-seed-ownership.ts:121-128, the shape quoted verbatim in triage
P2#13533's isSystem removal landed?not landed — PR #14528 is state: open, merged: false; rule-hooks.ts still carries the skip in stashAffectedRows, afterInsert, afterUpdate. Both worlds measured above
P3 cap / trailing-batch machinery intactRULE_RECOMPUTE_ROW_CAP = 1_000; revokeThenQueueRegrant reaches service.evaluateAllRulesForObject(objectName) via ruleRegrantQueue
P4 the three call sites⚠️ partly drifted — see below

P4, precisely.bootstrap-platform-admin.ts:623 (not :533) calls it, via security-plugin.ts:3330. meta resync reaches it indirectly, through bootstrapPlatformAdmin(ql, sets, { resync: true }) at resync.ts:192 — that file names no claimSeedOwnership of its own. And ensure-default-organization.ts:406 is not a call site of this function: it invokes an injectedoptions.claimSeedOwnership with a different signature (ql, organizationId, userId, options), supplied by the enterprise organizations package; auth-plugin.ts:1100 passes { logger } only, so on this repo that hook is never wired.

Both remaining entries also short-circuit at already_have_admin (bootstrap-platform-admin.ts:412) before the claim, so the loop is reachable only while the install has no platform admin. That narrows the exposure the card described — it does not change the fix.

Tests

All figures below are from the final commit 5808133df unless stated.

pnpm --filter @objectstack/plugin-security testTest Files 95 passed (95) · Tests 1795 passed (1795), exit 0.
pnpm --filter @objectstack/plugin-security typecheck — exit 0, verdict line check:test-typecheck: OK — @objectstack/plugin-security's test layer compiles under packages/plugins/plugin-security/tsconfig.test.json; tsc -p tsconfig.test.json --listFiles confirms both edited files are in the program (1 hit each), so that verdict really covers them.
npx eslint . --no-inline-config (the repo-wide scan, not a narrowing) — exit 0, no output, 100 s.
pnpm check:nul-bytescheck-nul-bytes: OK (scanned 8047 text file(s) -- 8047 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).

Gate family re-derived on the final commit with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (44 commands; the derivation's stderr names this repo and commit 5808133df, with no stale-tree warning) and run: 39 green, 5 NOT MEASURED — each reported as such by its own verdict line, never read as green or red:

  • check-test-completeness — exit 3, PREREQUISITE NOT MET — this gate grades a saved 'turbo run test' log, and no log was named.
  • check:dual-build-cjs-loads — exit 3, Run 'pnpm build' first. ⛔ This is NOT a pass: nothing was measured.
  • check:type-check-debt — exit 3, check-type-check-coverage: PREREQUISITE NOT MET
  • check:i18n — exit 1, check-i18n-bundles: PREREQUISITE NOT MET — the workspace CLI is not built
  • scripts/pm/check-half-states.mjs — hit its 300 s bound having printed only re-exec with --use-env-proxy: HTTPS_PROXY is set and node's fetch does not read it; it is a PM tool that reaches GitHub, not a tree gate.

CI runs all five after a full build.

The two gates that were red, and their final verdict lines

check:engine-double-contract was x RETAINED [update]: claim-seed-ownership.test.ts now pins 5 engine double(s), ledger records 1. The gate's own prescribed fix is to ratchet, since coverage grew in the direction the ledger wants. scripts/engine-double-contract.pinned.json is contended by sibling PRs, so origin/main was merged and committed first and the regeneration ran on the merged tree — never hand-edited. Regeneration reported 1 added or grown, 0 lost; the diff is one field, pinned: 1 to pinned: 5. Final verdict line:

check-engine-double-contract: OK — 758 pinned, 134 in the DEBT ledger, 3 exempt.

check-tenant-audit-census is green on both legs, and the self-test: 5 of 19 half was diagnosed rather than assumed:

✓ check-tenant-audit-census: OK -- 217 write call sites certified (145 decidable; 9 tenancy-enabled sites
PROVABLY carry no tenant context, 32 more unreadable), 23 prose figures held to the census.
✓ check-tenant-audit-census self-test: 19 cases pass (...)

The five failures reproduce locally at 39126dc02 and at no other commit measured. They are one root cause, not five: all five are the live-tree family, which asserts the committed census artefacts still match the tree, and at 39126dc02 the tree had drifted (objectName | undecidable where the committed row says schema.name, and the corpus-scale count 15 to 16). They were caused by this PR's diff at that commit and were already answered at 0418ccf8f by binding the engine calls at the schema.name call site, so the census's answer about this file is what it was before the paging fallback existed, with no ledger row degraded to buy a green gate.

Measured, never inferred — the self-test at each commit:

treeself-test
39126dc02 (this branch, before the call-site binding)✗ 5 of 19 failed
0418ccf8f (this branch, after it)✓ 19 pass
75adf11da (origin/main at this branch's base)✓ 19 pass
af44044a8 (origin/main, mid-range)✓ 19 pass
4d0d9445a (origin/main, current then)✓ 19 pass

So it was never red on origin/main, and it is not red now. node scripts/tenant-audit-census.mjs --write on the merged tree rewrites only the explicitly non-enforced corpus-scale block and its measurement date (tracked non-test sources scanned 534 to 539), which moved because main grew files — not because of this diff. That rewrite was reverted rather than committed: the gate is green with the committed values, and the block's own prose says those figures are required to be present and dated, and their values are not compared.

Ablation — three legs, all red, all provably restored

The subject is imported relatively (import { claimSeedOwnership } from './claim-seed-ownership.js'), so vitest compiles the source directly; no dist/ sits in the resolution path, which is the condition scripts/ablation-dist-preflight.mjs states for its own hazard ("any test whose subject resolves through the dependency's exports"). Each leg proves the mutation landed on disk before its colour is read — a globalThis marker (never a comment, which esbuild strips), the removed anchor text counted to 0, and the blob hash moved off HEAD's — and proves the restore landed after — blob hash back to HEAD's d5512db31c4b66c1b0080ec0227c6141ada7d486 and git diff HEAD -- PATH empty. The restore is git checkout HEAD -- ABSOLUTE_PATH, never the bare form, and the driver carries trap restore EXIT INT TERM.

legmutationon-disk proofresult
C — paging removed (new)the per-row-hook refusal rethrows instead of pagingmarker 1, removed anchor 0, hash 4319c4ab... off HEADTests 3 failed / 14 passed (17)
A — back to the pre-#14530 single-id loopreown reads ids and issues one by-id update eachmarker 1, removed anchor 0, hash 2851cc3e...Tests 11 failed / 6 passed (17)
B — matched set narrowed{ owner_id: SystemUserId.SYSTEM } dropped from UNOWNED_PREDICATESmarker 1, removed anchor 0, hash 08a8256b...Tests 10 failed / 7 passed (17)

Leg C is the one that judges patch round 1, and its red set is exactly the paging pins — nothing else moves:

× claims EVERY unowned row past MAX_BULK_PER_ROW_HOOK_ROWS, where one unpaged write is refused whole
× count is the SUM over every write of the pass, not just the last one
× stops rather than spinning when a fallback page matches rows but re-owns none

Legs A and B are the earlier rounds' legs, re-run on the paged implementation to confirm they were not carried off by it. Both still redden the equivalence and shape pins:

× re-owns NULL and usr_system rows to the admin, leaving human-owned rows untouched
× issues ONE predicate write per unowned shape — never one write per row
× claims exactly the id set the pre-#14530 single-id loop would have claimed
× the two predicates stay disjoint — no row is counted twice
× reports the affected-row count the write resolved, not a length it counted itself
× says "unknown" rather than 0 when a driver resolves something that is not a count
× a refused predicate write costs that predicate only — never the object or the run

The equivalence pin re-states the OLD rule (two scans at limit: 10_000, deduped) rather than calling into the implementation under test, on a fixture that includes an absent column, a present-but-undefined, an empty string and a usr_system_admin prefix collision, and asserts the new write claims that id set exactly. The over-ceiling pin compares against the uncapped unowned set instead, because past 10 000 the old rule was itself lossy and the new one must beat it, not match it.

Clause ②: no

Re-derived from the current diff on the final commit:

$ git diff -U0 origin/main...HEAD | grep -E '^\+\s*export '
$ echo $?
1

No new exported symbol. grep export cannot see a changed signature on an already-exported declaration, so that was checked separately: claimSeedOwnership is the file's only export, and its signature is byte-identical to origin/main's — (ql: any, adminUserId: string, options: ClaimOwnershipOptions = {}): Promise SUMMARY_ARRAY, where the summary element type { object: string; count: number } is also unchanged. CLAIM_PAGE_ROWS, MAX_CLAIM_PAGES, UNOWNED_PREDICATES, ObjectWriter, claimPredicate, isPerRowHookBudgetRefusal, affectedRowCount and idsFrom are all module-private. No carrier is owed.

One accept-set note that is not an export: the typeof ql.find !== 'function' precondition is retained, because the paged fallback reads.

Not done, deliberately

Disposition 3 (a boot-phase predicate) is out of scope and not this seat's to authorise; the hooks are not made to coalesce across writes; packages/spec is not touched.

⚠️packages/cli's run-dev-unbuilt-workspace.e2e.test.ts is a known repo-wide flake (#14648 / #14727, domain:cli is on it). It is unrelated to this branch and is neither fixed nor skipped here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

os-salesand others added 4 commits September 2, 2026 19:25
…per unowned shape
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
`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
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

10 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

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 dbf115284295b1989d4648dbfbd7e5f3f96357dcpackageMentionDocs.

Which tree this was computed on

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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI red on 9f17ab8a0 — the tracked run-dev-unbuilt-workspace flake, not this PR's

Test Core (1/6), read from the job log:

::error file=packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts,
line=317: AssertionError: expected 'SIGKILL' to be null
Duration 945.51s (import 373.97s, tests 2420.53s)

Not this PR's. The diff is three files, all in @objectstack/plugin-security (claim-seed-ownership.ts, its test, one changeset) — it touches nothing in @objectstack/cli, nothing the CLI boots, and no shared script that test loads. The failing test is the tracked flake anchored at #14648 (priority:p1, domain:cli, dispatched, dev on it since 18:15Z), and the asserted value is the harness's own kill signal firing at its 40 s cap — a cap derived from a measured ~22 s uncontended run, against a shard that here spent 2420 s of test time. A stopwatch, not a behaviour.

This is the fourth PR from this seat that the same signature has taken (#14528 and #14687 in the merge queue, #14712 and now this one on branch CI).

No re-run spent and nothing pushed for it. Patch round 1 is in flight on this branch — the seat's review is at 14530#issuecomment-5516113880 and requires the predicate write to be paged so objects above MAX_BULK_PER_ROW_HOOK_ROWS are still claimed — so the head moves shortly and CI re-runs on its own.

@objectstack/plugin-security's own suite was green on this head: Test Files 95 passed (95) · Tests 1791 passed (1791).


Generated by Claude Code

…cts 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
…ema.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
@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Blocker record — this PR's own gates, and what is queued behind them

Putting this on the PR because the seat's instructions to the dev agent went out over a channel that did not survive: the container running this session restarted at ~21:45Z and killed the agent working on this branch. Nothing was lost from the branch (its head 0418ccf8f was already pushed), but the in-flight instructions died with the agent, so they are restated here where they persist.

Gate 1 — check-tenant-audit-census, red at 39126dc02, addressed at 0418ccf8f

census : | packages/plugins/plugin-security/src/claim-seed-ownership.ts | update | objectName | undecidable | elevated | 1 |
Fix: node scripts/tenant-audit-census.mjs --write
✗ check-tenant-audit-census self-test: 5 of 19 case(s) failed.

The seat's ruling on it, restated: ⛔ do not run --write and call it done.objectName going undecidable is a signal, not a chore — before the paging change that write site's object name was statically decidable, and recording the downgrade trades the census's proving power for a green light. The right first question is whether the write site can stay decidable. The commit 0418ccf8f ("bind the seed-ownership engine calls at the schema.name call site") is exactly that answer, and it is the right one.

The self-test: 5 of 19 half is a separate question and must not be assumed to travel with it: read the full job output, and for each of the five decide whether this diff falsified its premise or whether it is red on origin/main too — that has to be measured on origin/main, never inferred. If it is red there, it is not this PR's.

⚠️ Note the gate's own words: "selfTest() returned without reaching its verdict, so no success line was printed." By this repo's standing rule, no verdict line means it did not pass — it cannot be read as green.

Gate 2 — check-engine-double-contract, red now at 0418ccf8f

x RETAINED [update]: packages/plugins/plugin-security/src/claim-seed-ownership.test.ts now pins 5 engine double(s), ledger records 1.
Coverage grew, which is the direction this ledger wants — run
`node scripts/check-engine-double-contract.mjs --write` and commit so the new double is ratcheted too.
check-engine-double-contract: 1 problem(s).

This one is the mechanical case: the gate says coverage grew in the direction the ledger wants, and ratcheting is the prescribed fix. Unlike gate 1, there is no downgrade being recorded.

⚠️But scripts/engine-double-contract.pinned.json is three-way contended right now — this PR, #14528 and #14726 all write it. So the ratchet is not a standalone --write: merge origin/main first, commit the merge, then regenerate on the merged tree and let check:engine-double-contract be the proof. ⛔ Never hand-edit the ledger.

(The two unrecognised [findOne] lines name other files and are not counted in the gate's single problem; they need no action here.)

What is not blocked

The paging work itself measured well and is not in question: 5000 rows went from 10 658 ms / 5000 engine writes to 448 ms / 2 writes, the cap / trailing-batch branch was measured to engage over-cap with zero change to plugin-sharing, and the P2 premise came back false and was handled by measuring both worlds with the per-object skip notice as the discriminator. The seat's review is at 14530#issuecomment-5516113880; the one substantive change still owed there is paging the predicate write so objects above MAX_BULK_PER_ROW_HOOK_ROWS are claimed rather than refused whole.

Queue position, stated so the wait is a record

This seat is at the maintainer's dispatch cap of 3 running dev agents (two post-restart recoveries plus PR #14528's merge round). This PR's remaining work is next after PR #14726's patch round. ⛔ No manual re-run is being spent on the run-dev-unbuilt-workspace flake that also hits this branch — that is #14648's, and it is already dispatched in domain:cli.


Generated by Claude Code

…ership 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
… 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
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.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf(plugin-security): claim seed ownership with one predicate write per unowned shape (#14530) - #14718

Merged
os-sales merged 11 commits into
mainfrom
claude/issue-14530-claim-seed-ownership-predicate-write
Sep 3, 2026
Merged

perf(plugin-security): claim seed ownership with one predicate write per unowned shape (#14530)#14718
os-sales merged 11 commits into
mainfrom
claude/issue-14530-claim-seed-ownership-predicate-write

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14530

Triage ruled disposition 2 (14530#issuecomment-5508899386): turn claimSeedOwnership's single-id loop into a predicate write per object, take the missing latency measurement first, and confirm at step 3 that the cap / trailing-batch branch in rule-hooks.ts actually engages on the new write shape. All three steps are below, with numbers.

Seat review 14530#issuecomment-5516113880 then ruled patch round 1 into this PR rather than deferring it: an over-ceiling predicate write is refused whole, which took an object above the ceiling from partially claimed to not claimed at all. That is fixed here, and measured. plugin-sharing is untouched throughout — that the fix needs no change there is the whole argument for disposition 2.

The change

claimSeedOwnership scanned every owner_id-declaring object twice at limit: 10_000 and then issued one single-idupdate per matched id — up to 20 000 full engine writes for one object. It now issues one predicate write per unowned shape, and pages that write only when the engine refuses it:

for(constwhereof[{owner_id: null},{owner_id: SystemUserId.SYSTEM}]){constaffected=awaitql.update(name,{owner_id: adminUserId},{ where,multi: true,context: SYSTEM_CTX});}

Two writes per object on every install small enough for them, which in practice is all of them. The scans are gone with the loop: the predicates they resolved are the predicates the writes now carry, so the matched set is unchanged row for row, and the per-object count is the sum of the affected-row counts the writes themselves resolve (#4639) rather than a length this function counted.

The two predicates stay two narrow writes rather than one OR/IN for the reason the scans were two — driver portability — and they stay disjoint in this order, because the NULL write lands adminUserId, which can never be usr_system (that target is refused at the top of the function).

Step 1 — the latency the card said was missing

The card is explicit that there is no number. Here are two, before and after, on a real ObjectQL engine.

Method.new ObjectQL() on InMemoryDriver (persistence: false), one object crm_lead declaring id/name/owner_id, N rows seeded straight through the driver (so setup pays no hook cost) alternating owner_id: null and owner_id: 'usr_system'. plugin-sharing's realbindRuleHooks is bound over a counting SharingRuleService double with one active rule on that object, so every branch of rule-hooks.ts the write shape can reach is counted. The window is claimSeedOwnership itself, process.hrtime.bigint() either side, plus a drain of ruleRegrantQueue. engine.update is wrapped to count the caller's writes. The "before" shape is the pre-change body inlined verbatim from origin/mainc616c2cc2; the "after" shape is imported from the built package.

⚠️Shared box. These are absolutes measured on a container running other agents' builds concurrently, so read the ratios, not the wall clock.

Baseline A — origin/main (c616c2cc2), i.e. before#13533

rowsshapeclaim msengine update callsevaluateAllForRecordskip notices
200before125.120001
200after18.6201
2 000before2 122.62 00001
2 000after170.5201
5 000before10 658.05 00001
5 000after448.0201

6.7x / 12.4x / 23.8x, and the write count stops scaling with N. evaluateAllForRecord: 0 and one skip notice per object are the signature of the pre-#13533 world: bindRuleHooks still opens afterInsert/afterUpdate with if (ctx?.session?.isSystem) { noteSystemWriteSkipped(); return; }, so no sharing materialisation runs at all on this path yet.

Baseline B — PR #14528 head (e9b612a7a), i.e. after#13533

Same harness, same object, run in a separate throwaway worktree at that PR's head with this branch's claim-seed-ownership.ts copied in and rebuilt.

rowsshapeclaim msengine update callsevaluateAllForRecordrevokeRuleGrantsForObjectevaluateAllRulesForObjectskip notices
800before490.0800800000
800after58.32800000
5 000before11 900.65 0005 000000
5 000after489.520220

8.4x and 24.3x. skipNotices: 0 is the discriminator that says these rows were measured in the post-#13533 world.

Step 2 to Step 3 — the cap / trailing-batch branch DOES engage

This is the stop-and-report condition, and it passes — conditionally on #13533, which is the honest reading:

  • 800 rows (400 per predicate, at or under RULE_RECOMPUTE_ROW_CAP = 1 000): the bounded branch runs — evaluateAllForRecord = 800, revokeRuleGrantsForObject = 0. Same per-record grant work as before, from 2 engine writes instead of 800.
  • 5 000 rows (2 500 per predicate, over the cap): readAffectedRows returns over-cap, afterUpdate takes revokeThenQueueRegrant, and the counters read evaluateAllForRecord = 0, revokeRuleGrantsForObject = 2, evaluateAllRulesForObject = 2 — one set-based revoke plus one queued full reconcile per predicate write. That is exactly the branch triage asked to see engaged, reached with no change to plugin-sharing.
  • The old shape never reaches it at any N: each write's row set is one row, affected.kind === 'rows' with a single id, and the cap can never fire. revokeRuleGrantsForObject = 0 at 5 000 rows confirms it.

On today's origin/main the branch is NOT reached#13533's PR #14528 is still open, so the isSystem skip in afterUpdate returns before affectedFrom(ctx) is ever consulted. The write-count and latency win is real and complete today; the cap-branch half arrives with #13533 and is measured above at that PR's head.

Patch round 1 — paging past the engine's per-row hook ceiling

A predicate write carries no limit, so the bound is the engine's own MAX_BULK_PER_ROW_HOOK_ROWS (10 000): beforeUpdate / afterUpdate hooks are contracted to fire per matched row on a predicate write (ADR-0058 D6), and every object carries such hooks in practice, so an over-sized write is refused whole — nothing written. owner_id is a record-access field, so "the object was not claimed" is a permission outcome, not an observability detail.

The refusal is a declared, total verdict whose own message names pagination as the remedy, so it is answered: take one page of ids off the top (CLAIM_PAGE_ROWS, half the ceiling) and re-attempt the whole set. Each page shrinks what is left until one write can carry it, and the pass ends on a whole-set write rather than on a count of pages.

Measured on the same 21 000-row object, one shape per row:

shaperows claimed of 21 000engine writesengine reads
pre-#14530 single-id loop (scans capped at limit: 10_000)10 00010 0002
unpaged predicate write (this PR before patch round 1)020
paged predicate write (this PR now)21 00083

Order is load-bearing, not cosmetic. Paging unconditionally measured 13x slower on the sizes every real install has: an id IN (...) page is evaluated by InMemoryDriver as a linear scan of the id list PER ROW (memory-matcher.ts, target.includes(value)), so an always-paged claim is quadratic there where the natural predicate is linear — 5 000 rows: 528 ms whole-set versus 5 865 ms always-paged, same engine, same driver, same row set. The page is what the engine's refusal buys, not the default.

CLAIM_PAGE_ROWS is derived from the ceiling rather than chosen (half of it), so it moves with the contract: the margin covers a driver's own bound-parameter limit on the id IN (...) list, and half the ceiling is still far above plugin-sharing's 1 000-row recompute cap, so a full page is still seen as a batch by the trailing-batch branch rather than recomputed row by row.

Termination does not rest on the page counter: a page that re-owns rows makes them stop matching the predicate, so the remainder strictly shrinks. MAX_CLAIM_PAGES is the belt for the one shape that reasoning does not cover — a driver reporting an affected count for rows it did not write — and hitting it is warned loudly, never silent. Three separate stop-and-warn paths cover an unreadable affected count, a page that matches rows but re-owns none, and a write that refuses for any code other than the declared per-row-hook budget.

#14719 was filed as the home for this work if paging turned out not to reach it. It did reach it — the number above is 21 000 of 21 000 — so that card is now a PM-seat judgement on this measurement, and this PR does not touch it.

⛔ Raising or exempting MAX_BULK_PER_ROW_HOOK_ROWS was refused: it is imported from @objectstack/spec/data and merely re-exported at engine.ts:7984, so moving it is a packages/spec contract change and the domain:spec seat's alone.

Premise re-verification (the card and triage read 4a37870; main has moved)

reading on origin/mainc616c2cc2
P1 single-id loop still thereclaim-seed-ownership.ts:121-128, the shape quoted verbatim in triage
P2#13533's isSystem removal landed?not landed — PR #14528 is state: open, merged: false; rule-hooks.ts still carries the skip in stashAffectedRows, afterInsert, afterUpdate. Both worlds measured above
P3 cap / trailing-batch machinery intactRULE_RECOMPUTE_ROW_CAP = 1_000; revokeThenQueueRegrant reaches service.evaluateAllRulesForObject(objectName) via ruleRegrantQueue
P4 the three call sites⚠️ partly drifted — see below

P4, precisely.bootstrap-platform-admin.ts:623 (not :533) calls it, via security-plugin.ts:3330. meta resync reaches it indirectly, through bootstrapPlatformAdmin(ql, sets, { resync: true }) at resync.ts:192 — that file names no claimSeedOwnership of its own. And ensure-default-organization.ts:406 is not a call site of this function: it invokes an injectedoptions.claimSeedOwnership with a different signature (ql, organizationId, userId, options), supplied by the enterprise organizations package; auth-plugin.ts:1100 passes { logger } only, so on this repo that hook is never wired.

Both remaining entries also short-circuit at already_have_admin (bootstrap-platform-admin.ts:412) before the claim, so the loop is reachable only while the install has no platform admin. That narrows the exposure the card described — it does not change the fix.

Tests

All figures below are from the final commit 5808133df unless stated.

pnpm --filter @objectstack/plugin-security testTest Files 95 passed (95) · Tests 1795 passed (1795), exit 0.
pnpm --filter @objectstack/plugin-security typecheck — exit 0, verdict line check:test-typecheck: OK — @objectstack/plugin-security's test layer compiles under packages/plugins/plugin-security/tsconfig.test.json; tsc -p tsconfig.test.json --listFiles confirms both edited files are in the program (1 hit each), so that verdict really covers them.
npx eslint . --no-inline-config (the repo-wide scan, not a narrowing) — exit 0, no output, 100 s.
pnpm check:nul-bytescheck-nul-bytes: OK (scanned 8047 text file(s) -- 8047 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).

Gate family re-derived on the final commit with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (44 commands; the derivation's stderr names this repo and commit 5808133df, with no stale-tree warning) and run: 39 green, 5 NOT MEASURED — each reported as such by its own verdict line, never read as green or red:

  • check-test-completeness — exit 3, PREREQUISITE NOT MET — this gate grades a saved 'turbo run test' log, and no log was named.
  • check:dual-build-cjs-loads — exit 3, Run 'pnpm build' first. ⛔ This is NOT a pass: nothing was measured.
  • check:type-check-debt — exit 3, check-type-check-coverage: PREREQUISITE NOT MET
  • check:i18n — exit 1, check-i18n-bundles: PREREQUISITE NOT MET — the workspace CLI is not built
  • scripts/pm/check-half-states.mjs — hit its 300 s bound having printed only re-exec with --use-env-proxy: HTTPS_PROXY is set and node's fetch does not read it; it is a PM tool that reaches GitHub, not a tree gate.

CI runs all five after a full build.

The two gates that were red, and their final verdict lines

check:engine-double-contract was x RETAINED [update]: claim-seed-ownership.test.ts now pins 5 engine double(s), ledger records 1. The gate's own prescribed fix is to ratchet, since coverage grew in the direction the ledger wants. scripts/engine-double-contract.pinned.json is contended by sibling PRs, so origin/main was merged and committed first and the regeneration ran on the merged tree — never hand-edited. Regeneration reported 1 added or grown, 0 lost; the diff is one field, pinned: 1 to pinned: 5. Final verdict line:

check-engine-double-contract: OK — 758 pinned, 134 in the DEBT ledger, 3 exempt.

check-tenant-audit-census is green on both legs, and the self-test: 5 of 19 half was diagnosed rather than assumed:

✓ check-tenant-audit-census: OK -- 217 write call sites certified (145 decidable; 9 tenancy-enabled sites
PROVABLY carry no tenant context, 32 more unreadable), 23 prose figures held to the census.
✓ check-tenant-audit-census self-test: 19 cases pass (...)

The five failures reproduce locally at 39126dc02 and at no other commit measured. They are one root cause, not five: all five are the live-tree family, which asserts the committed census artefacts still match the tree, and at 39126dc02 the tree had drifted (objectName | undecidable where the committed row says schema.name, and the corpus-scale count 15 to 16). They were caused by this PR's diff at that commit and were already answered at 0418ccf8f by binding the engine calls at the schema.name call site, so the census's answer about this file is what it was before the paging fallback existed, with no ledger row degraded to buy a green gate.

Measured, never inferred — the self-test at each commit:

treeself-test
39126dc02 (this branch, before the call-site binding)✗ 5 of 19 failed
0418ccf8f (this branch, after it)✓ 19 pass
75adf11da (origin/main at this branch's base)✓ 19 pass
af44044a8 (origin/main, mid-range)✓ 19 pass
4d0d9445a (origin/main, current then)✓ 19 pass

So it was never red on origin/main, and it is not red now. node scripts/tenant-audit-census.mjs --write on the merged tree rewrites only the explicitly non-enforced corpus-scale block and its measurement date (tracked non-test sources scanned 534 to 539), which moved because main grew files — not because of this diff. That rewrite was reverted rather than committed: the gate is green with the committed values, and the block's own prose says those figures are required to be present and dated, and their values are not compared.

Ablation — three legs, all red, all provably restored

The subject is imported relatively (import { claimSeedOwnership } from './claim-seed-ownership.js'), so vitest compiles the source directly; no dist/ sits in the resolution path, which is the condition scripts/ablation-dist-preflight.mjs states for its own hazard ("any test whose subject resolves through the dependency's exports"). Each leg proves the mutation landed on disk before its colour is read — a globalThis marker (never a comment, which esbuild strips), the removed anchor text counted to 0, and the blob hash moved off HEAD's — and proves the restore landed after — blob hash back to HEAD's d5512db31c4b66c1b0080ec0227c6141ada7d486 and git diff HEAD -- PATH empty. The restore is git checkout HEAD -- ABSOLUTE_PATH, never the bare form, and the driver carries trap restore EXIT INT TERM.

legmutationon-disk proofresult
C — paging removed (new)the per-row-hook refusal rethrows instead of pagingmarker 1, removed anchor 0, hash 4319c4ab... off HEADTests 3 failed / 14 passed (17)
A — back to the pre-#14530 single-id loopreown reads ids and issues one by-id update eachmarker 1, removed anchor 0, hash 2851cc3e...Tests 11 failed / 6 passed (17)
B — matched set narrowed{ owner_id: SystemUserId.SYSTEM } dropped from UNOWNED_PREDICATESmarker 1, removed anchor 0, hash 08a8256b...Tests 10 failed / 7 passed (17)

Leg C is the one that judges patch round 1, and its red set is exactly the paging pins — nothing else moves:

× claims EVERY unowned row past MAX_BULK_PER_ROW_HOOK_ROWS, where one unpaged write is refused whole
× count is the SUM over every write of the pass, not just the last one
× stops rather than spinning when a fallback page matches rows but re-owns none

Legs A and B are the earlier rounds' legs, re-run on the paged implementation to confirm they were not carried off by it. Both still redden the equivalence and shape pins:

× re-owns NULL and usr_system rows to the admin, leaving human-owned rows untouched
× issues ONE predicate write per unowned shape — never one write per row
× claims exactly the id set the pre-#14530 single-id loop would have claimed
× the two predicates stay disjoint — no row is counted twice
× reports the affected-row count the write resolved, not a length it counted itself
× says "unknown" rather than 0 when a driver resolves something that is not a count
× a refused predicate write costs that predicate only — never the object or the run

The equivalence pin re-states the OLD rule (two scans at limit: 10_000, deduped) rather than calling into the implementation under test, on a fixture that includes an absent column, a present-but-undefined, an empty string and a usr_system_admin prefix collision, and asserts the new write claims that id set exactly. The over-ceiling pin compares against the uncapped unowned set instead, because past 10 000 the old rule was itself lossy and the new one must beat it, not match it.

Clause ②: no

Re-derived from the current diff on the final commit:

$ git diff -U0 origin/main...HEAD | grep -E '^\+\s*export '
$ echo $?
1

No new exported symbol. grep export cannot see a changed signature on an already-exported declaration, so that was checked separately: claimSeedOwnership is the file's only export, and its signature is byte-identical to origin/main's — (ql: any, adminUserId: string, options: ClaimOwnershipOptions = {}): Promise SUMMARY_ARRAY, where the summary element type { object: string; count: number } is also unchanged. CLAIM_PAGE_ROWS, MAX_CLAIM_PAGES, UNOWNED_PREDICATES, ObjectWriter, claimPredicate, isPerRowHookBudgetRefusal, affectedRowCount and idsFrom are all module-private. No carrier is owed.

One accept-set note that is not an export: the typeof ql.find !== 'function' precondition is retained, because the paged fallback reads.

Not done, deliberately

Disposition 3 (a boot-phase predicate) is out of scope and not this seat's to authorise; the hooks are not made to coalesce across writes; packages/spec is not touched.

⚠️packages/cli's run-dev-unbuilt-workspace.e2e.test.ts is a known repo-wide flake (#14648 / #14727, domain:cli is on it). It is unrelated to this branch and is neither fixed nor skipped here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

os-salesand others added 4 commits September 2, 2026 19:25
…per unowned shape
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
`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
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

10 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

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 dbf115284295b1989d4648dbfbd7e5f3f96357dcpackageMentionDocs.

Which tree this was computed on

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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI red on 9f17ab8a0 — the tracked run-dev-unbuilt-workspace flake, not this PR's

Test Core (1/6), read from the job log:

::error file=packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts,
line=317: AssertionError: expected 'SIGKILL' to be null
Duration 945.51s (import 373.97s, tests 2420.53s)

Not this PR's. The diff is three files, all in @objectstack/plugin-security (claim-seed-ownership.ts, its test, one changeset) — it touches nothing in @objectstack/cli, nothing the CLI boots, and no shared script that test loads. The failing test is the tracked flake anchored at #14648 (priority:p1, domain:cli, dispatched, dev on it since 18:15Z), and the asserted value is the harness's own kill signal firing at its 40 s cap — a cap derived from a measured ~22 s uncontended run, against a shard that here spent 2420 s of test time. A stopwatch, not a behaviour.

This is the fourth PR from this seat that the same signature has taken (#14528 and #14687 in the merge queue, #14712 and now this one on branch CI).

No re-run spent and nothing pushed for it. Patch round 1 is in flight on this branch — the seat's review is at 14530#issuecomment-5516113880 and requires the predicate write to be paged so objects above MAX_BULK_PER_ROW_HOOK_ROWS are still claimed — so the head moves shortly and CI re-runs on its own.

@objectstack/plugin-security's own suite was green on this head: Test Files 95 passed (95) · Tests 1791 passed (1791).


Generated by Claude Code

…cts 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
…ema.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
@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Blocker record — this PR's own gates, and what is queued behind them

Putting this on the PR because the seat's instructions to the dev agent went out over a channel that did not survive: the container running this session restarted at ~21:45Z and killed the agent working on this branch. Nothing was lost from the branch (its head 0418ccf8f was already pushed), but the in-flight instructions died with the agent, so they are restated here where they persist.

Gate 1 — check-tenant-audit-census, red at 39126dc02, addressed at 0418ccf8f

census : | packages/plugins/plugin-security/src/claim-seed-ownership.ts | update | objectName | undecidable | elevated | 1 |
Fix: node scripts/tenant-audit-census.mjs --write
✗ check-tenant-audit-census self-test: 5 of 19 case(s) failed.

The seat's ruling on it, restated: ⛔ do not run --write and call it done.objectName going undecidable is a signal, not a chore — before the paging change that write site's object name was statically decidable, and recording the downgrade trades the census's proving power for a green light. The right first question is whether the write site can stay decidable. The commit 0418ccf8f ("bind the seed-ownership engine calls at the schema.name call site") is exactly that answer, and it is the right one.

The self-test: 5 of 19 half is a separate question and must not be assumed to travel with it: read the full job output, and for each of the five decide whether this diff falsified its premise or whether it is red on origin/main too — that has to be measured on origin/main, never inferred. If it is red there, it is not this PR's.

⚠️ Note the gate's own words: "selfTest() returned without reaching its verdict, so no success line was printed." By this repo's standing rule, no verdict line means it did not pass — it cannot be read as green.

Gate 2 — check-engine-double-contract, red now at 0418ccf8f

x RETAINED [update]: packages/plugins/plugin-security/src/claim-seed-ownership.test.ts now pins 5 engine double(s), ledger records 1.
Coverage grew, which is the direction this ledger wants — run
`node scripts/check-engine-double-contract.mjs --write` and commit so the new double is ratcheted too.
check-engine-double-contract: 1 problem(s).

This one is the mechanical case: the gate says coverage grew in the direction the ledger wants, and ratcheting is the prescribed fix. Unlike gate 1, there is no downgrade being recorded.

⚠️But scripts/engine-double-contract.pinned.json is three-way contended right now — this PR, #14528 and #14726 all write it. So the ratchet is not a standalone --write: merge origin/main first, commit the merge, then regenerate on the merged tree and let check:engine-double-contract be the proof. ⛔ Never hand-edit the ledger.

(The two unrecognised [findOne] lines name other files and are not counted in the gate's single problem; they need no action here.)

What is not blocked

The paging work itself measured well and is not in question: 5000 rows went from 10 658 ms / 5000 engine writes to 448 ms / 2 writes, the cap / trailing-batch branch was measured to engage over-cap with zero change to plugin-sharing, and the P2 premise came back false and was handled by measuring both worlds with the per-object skip notice as the discriminator. The seat's review is at 14530#issuecomment-5516113880; the one substantive change still owed there is paging the predicate write so objects above MAX_BULK_PER_ROW_HOOK_ROWS are claimed rather than refused whole.

Queue position, stated so the wait is a record

This seat is at the maintainer's dispatch cap of 3 running dev agents (two post-restart recoveries plus PR #14528's merge round). This PR's remaining work is next after PR #14726's patch round. ⛔ No manual re-run is being spent on the run-dev-unbuilt-workspace flake that also hits this branch — that is #14648's, and it is already dispatched in domain:cli.


Generated by Claude Code

…ership 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
… 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
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.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf(plugin-security): claim seed ownership with one predicate write per unowned shape (#14530) - #14718

Merged
os-sales merged 11 commits into
mainfrom
claude/issue-14530-claim-seed-ownership-predicate-write
Sep 3, 2026
Merged

perf(plugin-security): claim seed ownership with one predicate write per unowned shape (#14530)#14718
os-sales merged 11 commits into
mainfrom
claude/issue-14530-claim-seed-ownership-predicate-write

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14530

Triage ruled disposition 2 (14530#issuecomment-5508899386): turn claimSeedOwnership's single-id loop into a predicate write per object, take the missing latency measurement first, and confirm at step 3 that the cap / trailing-batch branch in rule-hooks.ts actually engages on the new write shape. All three steps are below, with numbers.

Seat review 14530#issuecomment-5516113880 then ruled patch round 1 into this PR rather than deferring it: an over-ceiling predicate write is refused whole, which took an object above the ceiling from partially claimed to not claimed at all. That is fixed here, and measured. plugin-sharing is untouched throughout — that the fix needs no change there is the whole argument for disposition 2.

The change

claimSeedOwnership scanned every owner_id-declaring object twice at limit: 10_000 and then issued one single-idupdate per matched id — up to 20 000 full engine writes for one object. It now issues one predicate write per unowned shape, and pages that write only when the engine refuses it:

for(constwhereof[{owner_id: null},{owner_id: SystemUserId.SYSTEM}]){constaffected=awaitql.update(name,{owner_id: adminUserId},{ where,multi: true,context: SYSTEM_CTX});}

Two writes per object on every install small enough for them, which in practice is all of them. The scans are gone with the loop: the predicates they resolved are the predicates the writes now carry, so the matched set is unchanged row for row, and the per-object count is the sum of the affected-row counts the writes themselves resolve (#4639) rather than a length this function counted.

The two predicates stay two narrow writes rather than one OR/IN for the reason the scans were two — driver portability — and they stay disjoint in this order, because the NULL write lands adminUserId, which can never be usr_system (that target is refused at the top of the function).

Step 1 — the latency the card said was missing

The card is explicit that there is no number. Here are two, before and after, on a real ObjectQL engine.

Method.new ObjectQL() on InMemoryDriver (persistence: false), one object crm_lead declaring id/name/owner_id, N rows seeded straight through the driver (so setup pays no hook cost) alternating owner_id: null and owner_id: 'usr_system'. plugin-sharing's realbindRuleHooks is bound over a counting SharingRuleService double with one active rule on that object, so every branch of rule-hooks.ts the write shape can reach is counted. The window is claimSeedOwnership itself, process.hrtime.bigint() either side, plus a drain of ruleRegrantQueue. engine.update is wrapped to count the caller's writes. The "before" shape is the pre-change body inlined verbatim from origin/mainc616c2cc2; the "after" shape is imported from the built package.

⚠️Shared box. These are absolutes measured on a container running other agents' builds concurrently, so read the ratios, not the wall clock.

Baseline A — origin/main (c616c2cc2), i.e. before#13533

rowsshapeclaim msengine update callsevaluateAllForRecordskip notices
200before125.120001
200after18.6201
2 000before2 122.62 00001
2 000after170.5201
5 000before10 658.05 00001
5 000after448.0201

6.7x / 12.4x / 23.8x, and the write count stops scaling with N. evaluateAllForRecord: 0 and one skip notice per object are the signature of the pre-#13533 world: bindRuleHooks still opens afterInsert/afterUpdate with if (ctx?.session?.isSystem) { noteSystemWriteSkipped(); return; }, so no sharing materialisation runs at all on this path yet.

Baseline B — PR #14528 head (e9b612a7a), i.e. after#13533

Same harness, same object, run in a separate throwaway worktree at that PR's head with this branch's claim-seed-ownership.ts copied in and rebuilt.

rowsshapeclaim msengine update callsevaluateAllForRecordrevokeRuleGrantsForObjectevaluateAllRulesForObjectskip notices
800before490.0800800000
800after58.32800000
5 000before11 900.65 0005 000000
5 000after489.520220

8.4x and 24.3x. skipNotices: 0 is the discriminator that says these rows were measured in the post-#13533 world.

Step 2 to Step 3 — the cap / trailing-batch branch DOES engage

This is the stop-and-report condition, and it passes — conditionally on #13533, which is the honest reading:

  • 800 rows (400 per predicate, at or under RULE_RECOMPUTE_ROW_CAP = 1 000): the bounded branch runs — evaluateAllForRecord = 800, revokeRuleGrantsForObject = 0. Same per-record grant work as before, from 2 engine writes instead of 800.
  • 5 000 rows (2 500 per predicate, over the cap): readAffectedRows returns over-cap, afterUpdate takes revokeThenQueueRegrant, and the counters read evaluateAllForRecord = 0, revokeRuleGrantsForObject = 2, evaluateAllRulesForObject = 2 — one set-based revoke plus one queued full reconcile per predicate write. That is exactly the branch triage asked to see engaged, reached with no change to plugin-sharing.
  • The old shape never reaches it at any N: each write's row set is one row, affected.kind === 'rows' with a single id, and the cap can never fire. revokeRuleGrantsForObject = 0 at 5 000 rows confirms it.

On today's origin/main the branch is NOT reached#13533's PR #14528 is still open, so the isSystem skip in afterUpdate returns before affectedFrom(ctx) is ever consulted. The write-count and latency win is real and complete today; the cap-branch half arrives with #13533 and is measured above at that PR's head.

Patch round 1 — paging past the engine's per-row hook ceiling

A predicate write carries no limit, so the bound is the engine's own MAX_BULK_PER_ROW_HOOK_ROWS (10 000): beforeUpdate / afterUpdate hooks are contracted to fire per matched row on a predicate write (ADR-0058 D6), and every object carries such hooks in practice, so an over-sized write is refused whole — nothing written. owner_id is a record-access field, so "the object was not claimed" is a permission outcome, not an observability detail.

The refusal is a declared, total verdict whose own message names pagination as the remedy, so it is answered: take one page of ids off the top (CLAIM_PAGE_ROWS, half the ceiling) and re-attempt the whole set. Each page shrinks what is left until one write can carry it, and the pass ends on a whole-set write rather than on a count of pages.

Measured on the same 21 000-row object, one shape per row:

shaperows claimed of 21 000engine writesengine reads
pre-#14530 single-id loop (scans capped at limit: 10_000)10 00010 0002
unpaged predicate write (this PR before patch round 1)020
paged predicate write (this PR now)21 00083

Order is load-bearing, not cosmetic. Paging unconditionally measured 13x slower on the sizes every real install has: an id IN (...) page is evaluated by InMemoryDriver as a linear scan of the id list PER ROW (memory-matcher.ts, target.includes(value)), so an always-paged claim is quadratic there where the natural predicate is linear — 5 000 rows: 528 ms whole-set versus 5 865 ms always-paged, same engine, same driver, same row set. The page is what the engine's refusal buys, not the default.

CLAIM_PAGE_ROWS is derived from the ceiling rather than chosen (half of it), so it moves with the contract: the margin covers a driver's own bound-parameter limit on the id IN (...) list, and half the ceiling is still far above plugin-sharing's 1 000-row recompute cap, so a full page is still seen as a batch by the trailing-batch branch rather than recomputed row by row.

Termination does not rest on the page counter: a page that re-owns rows makes them stop matching the predicate, so the remainder strictly shrinks. MAX_CLAIM_PAGES is the belt for the one shape that reasoning does not cover — a driver reporting an affected count for rows it did not write — and hitting it is warned loudly, never silent. Three separate stop-and-warn paths cover an unreadable affected count, a page that matches rows but re-owns none, and a write that refuses for any code other than the declared per-row-hook budget.

#14719 was filed as the home for this work if paging turned out not to reach it. It did reach it — the number above is 21 000 of 21 000 — so that card is now a PM-seat judgement on this measurement, and this PR does not touch it.

⛔ Raising or exempting MAX_BULK_PER_ROW_HOOK_ROWS was refused: it is imported from @objectstack/spec/data and merely re-exported at engine.ts:7984, so moving it is a packages/spec contract change and the domain:spec seat's alone.

Premise re-verification (the card and triage read 4a37870; main has moved)

reading on origin/mainc616c2cc2
P1 single-id loop still thereclaim-seed-ownership.ts:121-128, the shape quoted verbatim in triage
P2#13533's isSystem removal landed?not landed — PR #14528 is state: open, merged: false; rule-hooks.ts still carries the skip in stashAffectedRows, afterInsert, afterUpdate. Both worlds measured above
P3 cap / trailing-batch machinery intactRULE_RECOMPUTE_ROW_CAP = 1_000; revokeThenQueueRegrant reaches service.evaluateAllRulesForObject(objectName) via ruleRegrantQueue
P4 the three call sites⚠️ partly drifted — see below

P4, precisely.bootstrap-platform-admin.ts:623 (not :533) calls it, via security-plugin.ts:3330. meta resync reaches it indirectly, through bootstrapPlatformAdmin(ql, sets, { resync: true }) at resync.ts:192 — that file names no claimSeedOwnership of its own. And ensure-default-organization.ts:406 is not a call site of this function: it invokes an injectedoptions.claimSeedOwnership with a different signature (ql, organizationId, userId, options), supplied by the enterprise organizations package; auth-plugin.ts:1100 passes { logger } only, so on this repo that hook is never wired.

Both remaining entries also short-circuit at already_have_admin (bootstrap-platform-admin.ts:412) before the claim, so the loop is reachable only while the install has no platform admin. That narrows the exposure the card described — it does not change the fix.

Tests

All figures below are from the final commit 5808133df unless stated.

pnpm --filter @objectstack/plugin-security testTest Files 95 passed (95) · Tests 1795 passed (1795), exit 0.
pnpm --filter @objectstack/plugin-security typecheck — exit 0, verdict line check:test-typecheck: OK — @objectstack/plugin-security's test layer compiles under packages/plugins/plugin-security/tsconfig.test.json; tsc -p tsconfig.test.json --listFiles confirms both edited files are in the program (1 hit each), so that verdict really covers them.
npx eslint . --no-inline-config (the repo-wide scan, not a narrowing) — exit 0, no output, 100 s.
pnpm check:nul-bytescheck-nul-bytes: OK (scanned 8047 text file(s) -- 8047 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).

Gate family re-derived on the final commit with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (44 commands; the derivation's stderr names this repo and commit 5808133df, with no stale-tree warning) and run: 39 green, 5 NOT MEASURED — each reported as such by its own verdict line, never read as green or red:

  • check-test-completeness — exit 3, PREREQUISITE NOT MET — this gate grades a saved 'turbo run test' log, and no log was named.
  • check:dual-build-cjs-loads — exit 3, Run 'pnpm build' first. ⛔ This is NOT a pass: nothing was measured.
  • check:type-check-debt — exit 3, check-type-check-coverage: PREREQUISITE NOT MET
  • check:i18n — exit 1, check-i18n-bundles: PREREQUISITE NOT MET — the workspace CLI is not built
  • scripts/pm/check-half-states.mjs — hit its 300 s bound having printed only re-exec with --use-env-proxy: HTTPS_PROXY is set and node's fetch does not read it; it is a PM tool that reaches GitHub, not a tree gate.

CI runs all five after a full build.

The two gates that were red, and their final verdict lines

check:engine-double-contract was x RETAINED [update]: claim-seed-ownership.test.ts now pins 5 engine double(s), ledger records 1. The gate's own prescribed fix is to ratchet, since coverage grew in the direction the ledger wants. scripts/engine-double-contract.pinned.json is contended by sibling PRs, so origin/main was merged and committed first and the regeneration ran on the merged tree — never hand-edited. Regeneration reported 1 added or grown, 0 lost; the diff is one field, pinned: 1 to pinned: 5. Final verdict line:

check-engine-double-contract: OK — 758 pinned, 134 in the DEBT ledger, 3 exempt.

check-tenant-audit-census is green on both legs, and the self-test: 5 of 19 half was diagnosed rather than assumed:

✓ check-tenant-audit-census: OK -- 217 write call sites certified (145 decidable; 9 tenancy-enabled sites
PROVABLY carry no tenant context, 32 more unreadable), 23 prose figures held to the census.
✓ check-tenant-audit-census self-test: 19 cases pass (...)

The five failures reproduce locally at 39126dc02 and at no other commit measured. They are one root cause, not five: all five are the live-tree family, which asserts the committed census artefacts still match the tree, and at 39126dc02 the tree had drifted (objectName | undecidable where the committed row says schema.name, and the corpus-scale count 15 to 16). They were caused by this PR's diff at that commit and were already answered at 0418ccf8f by binding the engine calls at the schema.name call site, so the census's answer about this file is what it was before the paging fallback existed, with no ledger row degraded to buy a green gate.

Measured, never inferred — the self-test at each commit:

treeself-test
39126dc02 (this branch, before the call-site binding)✗ 5 of 19 failed
0418ccf8f (this branch, after it)✓ 19 pass
75adf11da (origin/main at this branch's base)✓ 19 pass
af44044a8 (origin/main, mid-range)✓ 19 pass
4d0d9445a (origin/main, current then)✓ 19 pass

So it was never red on origin/main, and it is not red now. node scripts/tenant-audit-census.mjs --write on the merged tree rewrites only the explicitly non-enforced corpus-scale block and its measurement date (tracked non-test sources scanned 534 to 539), which moved because main grew files — not because of this diff. That rewrite was reverted rather than committed: the gate is green with the committed values, and the block's own prose says those figures are required to be present and dated, and their values are not compared.

Ablation — three legs, all red, all provably restored

The subject is imported relatively (import { claimSeedOwnership } from './claim-seed-ownership.js'), so vitest compiles the source directly; no dist/ sits in the resolution path, which is the condition scripts/ablation-dist-preflight.mjs states for its own hazard ("any test whose subject resolves through the dependency's exports"). Each leg proves the mutation landed on disk before its colour is read — a globalThis marker (never a comment, which esbuild strips), the removed anchor text counted to 0, and the blob hash moved off HEAD's — and proves the restore landed after — blob hash back to HEAD's d5512db31c4b66c1b0080ec0227c6141ada7d486 and git diff HEAD -- PATH empty. The restore is git checkout HEAD -- ABSOLUTE_PATH, never the bare form, and the driver carries trap restore EXIT INT TERM.

legmutationon-disk proofresult
C — paging removed (new)the per-row-hook refusal rethrows instead of pagingmarker 1, removed anchor 0, hash 4319c4ab... off HEADTests 3 failed / 14 passed (17)
A — back to the pre-#14530 single-id loopreown reads ids and issues one by-id update eachmarker 1, removed anchor 0, hash 2851cc3e...Tests 11 failed / 6 passed (17)
B — matched set narrowed{ owner_id: SystemUserId.SYSTEM } dropped from UNOWNED_PREDICATESmarker 1, removed anchor 0, hash 08a8256b...Tests 10 failed / 7 passed (17)

Leg C is the one that judges patch round 1, and its red set is exactly the paging pins — nothing else moves:

× claims EVERY unowned row past MAX_BULK_PER_ROW_HOOK_ROWS, where one unpaged write is refused whole
× count is the SUM over every write of the pass, not just the last one
× stops rather than spinning when a fallback page matches rows but re-owns none

Legs A and B are the earlier rounds' legs, re-run on the paged implementation to confirm they were not carried off by it. Both still redden the equivalence and shape pins:

× re-owns NULL and usr_system rows to the admin, leaving human-owned rows untouched
× issues ONE predicate write per unowned shape — never one write per row
× claims exactly the id set the pre-#14530 single-id loop would have claimed
× the two predicates stay disjoint — no row is counted twice
× reports the affected-row count the write resolved, not a length it counted itself
× says "unknown" rather than 0 when a driver resolves something that is not a count
× a refused predicate write costs that predicate only — never the object or the run

The equivalence pin re-states the OLD rule (two scans at limit: 10_000, deduped) rather than calling into the implementation under test, on a fixture that includes an absent column, a present-but-undefined, an empty string and a usr_system_admin prefix collision, and asserts the new write claims that id set exactly. The over-ceiling pin compares against the uncapped unowned set instead, because past 10 000 the old rule was itself lossy and the new one must beat it, not match it.

Clause ②: no

Re-derived from the current diff on the final commit:

$ git diff -U0 origin/main...HEAD | grep -E '^\+\s*export '
$ echo $?
1

No new exported symbol. grep export cannot see a changed signature on an already-exported declaration, so that was checked separately: claimSeedOwnership is the file's only export, and its signature is byte-identical to origin/main's — (ql: any, adminUserId: string, options: ClaimOwnershipOptions = {}): Promise SUMMARY_ARRAY, where the summary element type { object: string; count: number } is also unchanged. CLAIM_PAGE_ROWS, MAX_CLAIM_PAGES, UNOWNED_PREDICATES, ObjectWriter, claimPredicate, isPerRowHookBudgetRefusal, affectedRowCount and idsFrom are all module-private. No carrier is owed.

One accept-set note that is not an export: the typeof ql.find !== 'function' precondition is retained, because the paged fallback reads.

Not done, deliberately

Disposition 3 (a boot-phase predicate) is out of scope and not this seat's to authorise; the hooks are not made to coalesce across writes; packages/spec is not touched.

⚠️packages/cli's run-dev-unbuilt-workspace.e2e.test.ts is a known repo-wide flake (#14648 / #14727, domain:cli is on it). It is unrelated to this branch and is neither fixed nor skipped here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

os-salesand others added 4 commits September 2, 2026 19:25
…per unowned shape
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
`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
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

10 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

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 dbf115284295b1989d4648dbfbd7e5f3f96357dcpackageMentionDocs.

Which tree this was computed on

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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI red on 9f17ab8a0 — the tracked run-dev-unbuilt-workspace flake, not this PR's

Test Core (1/6), read from the job log:

::error file=packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts,
line=317: AssertionError: expected 'SIGKILL' to be null
Duration 945.51s (import 373.97s, tests 2420.53s)

Not this PR's. The diff is three files, all in @objectstack/plugin-security (claim-seed-ownership.ts, its test, one changeset) — it touches nothing in @objectstack/cli, nothing the CLI boots, and no shared script that test loads. The failing test is the tracked flake anchored at #14648 (priority:p1, domain:cli, dispatched, dev on it since 18:15Z), and the asserted value is the harness's own kill signal firing at its 40 s cap — a cap derived from a measured ~22 s uncontended run, against a shard that here spent 2420 s of test time. A stopwatch, not a behaviour.

This is the fourth PR from this seat that the same signature has taken (#14528 and #14687 in the merge queue, #14712 and now this one on branch CI).

No re-run spent and nothing pushed for it. Patch round 1 is in flight on this branch — the seat's review is at 14530#issuecomment-5516113880 and requires the predicate write to be paged so objects above MAX_BULK_PER_ROW_HOOK_ROWS are still claimed — so the head moves shortly and CI re-runs on its own.

@objectstack/plugin-security's own suite was green on this head: Test Files 95 passed (95) · Tests 1791 passed (1791).


Generated by Claude Code

…cts 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
…ema.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
@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Blocker record — this PR's own gates, and what is queued behind them

Putting this on the PR because the seat's instructions to the dev agent went out over a channel that did not survive: the container running this session restarted at ~21:45Z and killed the agent working on this branch. Nothing was lost from the branch (its head 0418ccf8f was already pushed), but the in-flight instructions died with the agent, so they are restated here where they persist.

Gate 1 — check-tenant-audit-census, red at 39126dc02, addressed at 0418ccf8f

census : | packages/plugins/plugin-security/src/claim-seed-ownership.ts | update | objectName | undecidable | elevated | 1 |
Fix: node scripts/tenant-audit-census.mjs --write
✗ check-tenant-audit-census self-test: 5 of 19 case(s) failed.

The seat's ruling on it, restated: ⛔ do not run --write and call it done.objectName going undecidable is a signal, not a chore — before the paging change that write site's object name was statically decidable, and recording the downgrade trades the census's proving power for a green light. The right first question is whether the write site can stay decidable. The commit 0418ccf8f ("bind the seed-ownership engine calls at the schema.name call site") is exactly that answer, and it is the right one.

The self-test: 5 of 19 half is a separate question and must not be assumed to travel with it: read the full job output, and for each of the five decide whether this diff falsified its premise or whether it is red on origin/main too — that has to be measured on origin/main, never inferred. If it is red there, it is not this PR's.

⚠️ Note the gate's own words: "selfTest() returned without reaching its verdict, so no success line was printed." By this repo's standing rule, no verdict line means it did not pass — it cannot be read as green.

Gate 2 — check-engine-double-contract, red now at 0418ccf8f

x RETAINED [update]: packages/plugins/plugin-security/src/claim-seed-ownership.test.ts now pins 5 engine double(s), ledger records 1.
Coverage grew, which is the direction this ledger wants — run
`node scripts/check-engine-double-contract.mjs --write` and commit so the new double is ratcheted too.
check-engine-double-contract: 1 problem(s).

This one is the mechanical case: the gate says coverage grew in the direction the ledger wants, and ratcheting is the prescribed fix. Unlike gate 1, there is no downgrade being recorded.

⚠️But scripts/engine-double-contract.pinned.json is three-way contended right now — this PR, #14528 and #14726 all write it. So the ratchet is not a standalone --write: merge origin/main first, commit the merge, then regenerate on the merged tree and let check:engine-double-contract be the proof. ⛔ Never hand-edit the ledger.

(The two unrecognised [findOne] lines name other files and are not counted in the gate's single problem; they need no action here.)

What is not blocked

The paging work itself measured well and is not in question: 5000 rows went from 10 658 ms / 5000 engine writes to 448 ms / 2 writes, the cap / trailing-batch branch was measured to engage over-cap with zero change to plugin-sharing, and the P2 premise came back false and was handled by measuring both worlds with the per-object skip notice as the discriminator. The seat's review is at 14530#issuecomment-5516113880; the one substantive change still owed there is paging the predicate write so objects above MAX_BULK_PER_ROW_HOOK_ROWS are claimed rather than refused whole.

Queue position, stated so the wait is a record

This seat is at the maintainer's dispatch cap of 3 running dev agents (two post-restart recoveries plus PR #14528's merge round). This PR's remaining work is next after PR #14726's patch round. ⛔ No manual re-run is being spent on the run-dev-unbuilt-workspace flake that also hits this branch — that is #14648's, and it is already dispatched in domain:cli.


Generated by Claude Code

…ership 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
… 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
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.

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

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

perf(plugin-security): claim seed ownership with one predicate write per unowned shape (#14530) - #14718

Merged
os-sales merged 11 commits into
mainfrom
claude/issue-14530-claim-seed-ownership-predicate-write
Sep 3, 2026
Merged

perf(plugin-security): claim seed ownership with one predicate write per unowned shape (#14530)#14718
os-sales merged 11 commits into
mainfrom
claude/issue-14530-claim-seed-ownership-predicate-write

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14530

Triage ruled disposition 2 (14530#issuecomment-5508899386): turn claimSeedOwnership's single-id loop into a predicate write per object, take the missing latency measurement first, and confirm at step 3 that the cap / trailing-batch branch in rule-hooks.ts actually engages on the new write shape. All three steps are below, with numbers.

Seat review 14530#issuecomment-5516113880 then ruled patch round 1 into this PR rather than deferring it: an over-ceiling predicate write is refused whole, which took an object above the ceiling from partially claimed to not claimed at all. That is fixed here, and measured. plugin-sharing is untouched throughout — that the fix needs no change there is the whole argument for disposition 2.

The change

claimSeedOwnership scanned every owner_id-declaring object twice at limit: 10_000 and then issued one single-idupdate per matched id — up to 20 000 full engine writes for one object. It now issues one predicate write per unowned shape, and pages that write only when the engine refuses it:

for(constwhereof[{owner_id: null},{owner_id: SystemUserId.SYSTEM}]){constaffected=awaitql.update(name,{owner_id: adminUserId},{ where,multi: true,context: SYSTEM_CTX});}

Two writes per object on every install small enough for them, which in practice is all of them. The scans are gone with the loop: the predicates they resolved are the predicates the writes now carry, so the matched set is unchanged row for row, and the per-object count is the sum of the affected-row counts the writes themselves resolve (#4639) rather than a length this function counted.

The two predicates stay two narrow writes rather than one OR/IN for the reason the scans were two — driver portability — and they stay disjoint in this order, because the NULL write lands adminUserId, which can never be usr_system (that target is refused at the top of the function).

Step 1 — the latency the card said was missing

The card is explicit that there is no number. Here are two, before and after, on a real ObjectQL engine.

Method.new ObjectQL() on InMemoryDriver (persistence: false), one object crm_lead declaring id/name/owner_id, N rows seeded straight through the driver (so setup pays no hook cost) alternating owner_id: null and owner_id: 'usr_system'. plugin-sharing's realbindRuleHooks is bound over a counting SharingRuleService double with one active rule on that object, so every branch of rule-hooks.ts the write shape can reach is counted. The window is claimSeedOwnership itself, process.hrtime.bigint() either side, plus a drain of ruleRegrantQueue. engine.update is wrapped to count the caller's writes. The "before" shape is the pre-change body inlined verbatim from origin/mainc616c2cc2; the "after" shape is imported from the built package.

⚠️Shared box. These are absolutes measured on a container running other agents' builds concurrently, so read the ratios, not the wall clock.

Baseline A — origin/main (c616c2cc2), i.e. before#13533

rowsshapeclaim msengine update callsevaluateAllForRecordskip notices
200before125.120001
200after18.6201
2 000before2 122.62 00001
2 000after170.5201
5 000before10 658.05 00001
5 000after448.0201

6.7x / 12.4x / 23.8x, and the write count stops scaling with N. evaluateAllForRecord: 0 and one skip notice per object are the signature of the pre-#13533 world: bindRuleHooks still opens afterInsert/afterUpdate with if (ctx?.session?.isSystem) { noteSystemWriteSkipped(); return; }, so no sharing materialisation runs at all on this path yet.

Baseline B — PR #14528 head (e9b612a7a), i.e. after#13533

Same harness, same object, run in a separate throwaway worktree at that PR's head with this branch's claim-seed-ownership.ts copied in and rebuilt.

rowsshapeclaim msengine update callsevaluateAllForRecordrevokeRuleGrantsForObjectevaluateAllRulesForObjectskip notices
800before490.0800800000
800after58.32800000
5 000before11 900.65 0005 000000
5 000after489.520220

8.4x and 24.3x. skipNotices: 0 is the discriminator that says these rows were measured in the post-#13533 world.

Step 2 to Step 3 — the cap / trailing-batch branch DOES engage

This is the stop-and-report condition, and it passes — conditionally on #13533, which is the honest reading:

  • 800 rows (400 per predicate, at or under RULE_RECOMPUTE_ROW_CAP = 1 000): the bounded branch runs — evaluateAllForRecord = 800, revokeRuleGrantsForObject = 0. Same per-record grant work as before, from 2 engine writes instead of 800.
  • 5 000 rows (2 500 per predicate, over the cap): readAffectedRows returns over-cap, afterUpdate takes revokeThenQueueRegrant, and the counters read evaluateAllForRecord = 0, revokeRuleGrantsForObject = 2, evaluateAllRulesForObject = 2 — one set-based revoke plus one queued full reconcile per predicate write. That is exactly the branch triage asked to see engaged, reached with no change to plugin-sharing.
  • The old shape never reaches it at any N: each write's row set is one row, affected.kind === 'rows' with a single id, and the cap can never fire. revokeRuleGrantsForObject = 0 at 5 000 rows confirms it.

On today's origin/main the branch is NOT reached#13533's PR #14528 is still open, so the isSystem skip in afterUpdate returns before affectedFrom(ctx) is ever consulted. The write-count and latency win is real and complete today; the cap-branch half arrives with #13533 and is measured above at that PR's head.

Patch round 1 — paging past the engine's per-row hook ceiling

A predicate write carries no limit, so the bound is the engine's own MAX_BULK_PER_ROW_HOOK_ROWS (10 000): beforeUpdate / afterUpdate hooks are contracted to fire per matched row on a predicate write (ADR-0058 D6), and every object carries such hooks in practice, so an over-sized write is refused whole — nothing written. owner_id is a record-access field, so "the object was not claimed" is a permission outcome, not an observability detail.

The refusal is a declared, total verdict whose own message names pagination as the remedy, so it is answered: take one page of ids off the top (CLAIM_PAGE_ROWS, half the ceiling) and re-attempt the whole set. Each page shrinks what is left until one write can carry it, and the pass ends on a whole-set write rather than on a count of pages.

Measured on the same 21 000-row object, one shape per row:

shaperows claimed of 21 000engine writesengine reads
pre-#14530 single-id loop (scans capped at limit: 10_000)10 00010 0002
unpaged predicate write (this PR before patch round 1)020
paged predicate write (this PR now)21 00083

Order is load-bearing, not cosmetic. Paging unconditionally measured 13x slower on the sizes every real install has: an id IN (...) page is evaluated by InMemoryDriver as a linear scan of the id list PER ROW (memory-matcher.ts, target.includes(value)), so an always-paged claim is quadratic there where the natural predicate is linear — 5 000 rows: 528 ms whole-set versus 5 865 ms always-paged, same engine, same driver, same row set. The page is what the engine's refusal buys, not the default.

CLAIM_PAGE_ROWS is derived from the ceiling rather than chosen (half of it), so it moves with the contract: the margin covers a driver's own bound-parameter limit on the id IN (...) list, and half the ceiling is still far above plugin-sharing's 1 000-row recompute cap, so a full page is still seen as a batch by the trailing-batch branch rather than recomputed row by row.

Termination does not rest on the page counter: a page that re-owns rows makes them stop matching the predicate, so the remainder strictly shrinks. MAX_CLAIM_PAGES is the belt for the one shape that reasoning does not cover — a driver reporting an affected count for rows it did not write — and hitting it is warned loudly, never silent. Three separate stop-and-warn paths cover an unreadable affected count, a page that matches rows but re-owns none, and a write that refuses for any code other than the declared per-row-hook budget.

#14719 was filed as the home for this work if paging turned out not to reach it. It did reach it — the number above is 21 000 of 21 000 — so that card is now a PM-seat judgement on this measurement, and this PR does not touch it.

⛔ Raising or exempting MAX_BULK_PER_ROW_HOOK_ROWS was refused: it is imported from @objectstack/spec/data and merely re-exported at engine.ts:7984, so moving it is a packages/spec contract change and the domain:spec seat's alone.

Premise re-verification (the card and triage read 4a37870; main has moved)

reading on origin/mainc616c2cc2
P1 single-id loop still thereclaim-seed-ownership.ts:121-128, the shape quoted verbatim in triage
P2#13533's isSystem removal landed?not landed — PR #14528 is state: open, merged: false; rule-hooks.ts still carries the skip in stashAffectedRows, afterInsert, afterUpdate. Both worlds measured above
P3 cap / trailing-batch machinery intactRULE_RECOMPUTE_ROW_CAP = 1_000; revokeThenQueueRegrant reaches service.evaluateAllRulesForObject(objectName) via ruleRegrantQueue
P4 the three call sites⚠️ partly drifted — see below

P4, precisely.bootstrap-platform-admin.ts:623 (not :533) calls it, via security-plugin.ts:3330. meta resync reaches it indirectly, through bootstrapPlatformAdmin(ql, sets, { resync: true }) at resync.ts:192 — that file names no claimSeedOwnership of its own. And ensure-default-organization.ts:406 is not a call site of this function: it invokes an injectedoptions.claimSeedOwnership with a different signature (ql, organizationId, userId, options), supplied by the enterprise organizations package; auth-plugin.ts:1100 passes { logger } only, so on this repo that hook is never wired.

Both remaining entries also short-circuit at already_have_admin (bootstrap-platform-admin.ts:412) before the claim, so the loop is reachable only while the install has no platform admin. That narrows the exposure the card described — it does not change the fix.

Tests

All figures below are from the final commit 5808133df unless stated.

pnpm --filter @objectstack/plugin-security testTest Files 95 passed (95) · Tests 1795 passed (1795), exit 0.
pnpm --filter @objectstack/plugin-security typecheck — exit 0, verdict line check:test-typecheck: OK — @objectstack/plugin-security's test layer compiles under packages/plugins/plugin-security/tsconfig.test.json; tsc -p tsconfig.test.json --listFiles confirms both edited files are in the program (1 hit each), so that verdict really covers them.
npx eslint . --no-inline-config (the repo-wide scan, not a narrowing) — exit 0, no output, 100 s.
pnpm check:nul-bytescheck-nul-bytes: OK (scanned 8047 text file(s) -- 8047 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).

Gate family re-derived on the final commit with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (44 commands; the derivation's stderr names this repo and commit 5808133df, with no stale-tree warning) and run: 39 green, 5 NOT MEASURED — each reported as such by its own verdict line, never read as green or red:

  • check-test-completeness — exit 3, PREREQUISITE NOT MET — this gate grades a saved 'turbo run test' log, and no log was named.
  • check:dual-build-cjs-loads — exit 3, Run 'pnpm build' first. ⛔ This is NOT a pass: nothing was measured.
  • check:type-check-debt — exit 3, check-type-check-coverage: PREREQUISITE NOT MET
  • check:i18n — exit 1, check-i18n-bundles: PREREQUISITE NOT MET — the workspace CLI is not built
  • scripts/pm/check-half-states.mjs — hit its 300 s bound having printed only re-exec with --use-env-proxy: HTTPS_PROXY is set and node's fetch does not read it; it is a PM tool that reaches GitHub, not a tree gate.

CI runs all five after a full build.

The two gates that were red, and their final verdict lines

check:engine-double-contract was x RETAINED [update]: claim-seed-ownership.test.ts now pins 5 engine double(s), ledger records 1. The gate's own prescribed fix is to ratchet, since coverage grew in the direction the ledger wants. scripts/engine-double-contract.pinned.json is contended by sibling PRs, so origin/main was merged and committed first and the regeneration ran on the merged tree — never hand-edited. Regeneration reported 1 added or grown, 0 lost; the diff is one field, pinned: 1 to pinned: 5. Final verdict line:

check-engine-double-contract: OK — 758 pinned, 134 in the DEBT ledger, 3 exempt.

check-tenant-audit-census is green on both legs, and the self-test: 5 of 19 half was diagnosed rather than assumed:

✓ check-tenant-audit-census: OK -- 217 write call sites certified (145 decidable; 9 tenancy-enabled sites
PROVABLY carry no tenant context, 32 more unreadable), 23 prose figures held to the census.
✓ check-tenant-audit-census self-test: 19 cases pass (...)

The five failures reproduce locally at 39126dc02 and at no other commit measured. They are one root cause, not five: all five are the live-tree family, which asserts the committed census artefacts still match the tree, and at 39126dc02 the tree had drifted (objectName | undecidable where the committed row says schema.name, and the corpus-scale count 15 to 16). They were caused by this PR's diff at that commit and were already answered at 0418ccf8f by binding the engine calls at the schema.name call site, so the census's answer about this file is what it was before the paging fallback existed, with no ledger row degraded to buy a green gate.

Measured, never inferred — the self-test at each commit:

treeself-test
39126dc02 (this branch, before the call-site binding)✗ 5 of 19 failed
0418ccf8f (this branch, after it)✓ 19 pass
75adf11da (origin/main at this branch's base)✓ 19 pass
af44044a8 (origin/main, mid-range)✓ 19 pass
4d0d9445a (origin/main, current then)✓ 19 pass

So it was never red on origin/main, and it is not red now. node scripts/tenant-audit-census.mjs --write on the merged tree rewrites only the explicitly non-enforced corpus-scale block and its measurement date (tracked non-test sources scanned 534 to 539), which moved because main grew files — not because of this diff. That rewrite was reverted rather than committed: the gate is green with the committed values, and the block's own prose says those figures are required to be present and dated, and their values are not compared.

Ablation — three legs, all red, all provably restored

The subject is imported relatively (import { claimSeedOwnership } from './claim-seed-ownership.js'), so vitest compiles the source directly; no dist/ sits in the resolution path, which is the condition scripts/ablation-dist-preflight.mjs states for its own hazard ("any test whose subject resolves through the dependency's exports"). Each leg proves the mutation landed on disk before its colour is read — a globalThis marker (never a comment, which esbuild strips), the removed anchor text counted to 0, and the blob hash moved off HEAD's — and proves the restore landed after — blob hash back to HEAD's d5512db31c4b66c1b0080ec0227c6141ada7d486 and git diff HEAD -- PATH empty. The restore is git checkout HEAD -- ABSOLUTE_PATH, never the bare form, and the driver carries trap restore EXIT INT TERM.

legmutationon-disk proofresult
C — paging removed (new)the per-row-hook refusal rethrows instead of pagingmarker 1, removed anchor 0, hash 4319c4ab... off HEADTests 3 failed / 14 passed (17)
A — back to the pre-#14530 single-id loopreown reads ids and issues one by-id update eachmarker 1, removed anchor 0, hash 2851cc3e...Tests 11 failed / 6 passed (17)
B — matched set narrowed{ owner_id: SystemUserId.SYSTEM } dropped from UNOWNED_PREDICATESmarker 1, removed anchor 0, hash 08a8256b...Tests 10 failed / 7 passed (17)

Leg C is the one that judges patch round 1, and its red set is exactly the paging pins — nothing else moves:

× claims EVERY unowned row past MAX_BULK_PER_ROW_HOOK_ROWS, where one unpaged write is refused whole
× count is the SUM over every write of the pass, not just the last one
× stops rather than spinning when a fallback page matches rows but re-owns none

Legs A and B are the earlier rounds' legs, re-run on the paged implementation to confirm they were not carried off by it. Both still redden the equivalence and shape pins:

× re-owns NULL and usr_system rows to the admin, leaving human-owned rows untouched
× issues ONE predicate write per unowned shape — never one write per row
× claims exactly the id set the pre-#14530 single-id loop would have claimed
× the two predicates stay disjoint — no row is counted twice
× reports the affected-row count the write resolved, not a length it counted itself
× says "unknown" rather than 0 when a driver resolves something that is not a count
× a refused predicate write costs that predicate only — never the object or the run

The equivalence pin re-states the OLD rule (two scans at limit: 10_000, deduped) rather than calling into the implementation under test, on a fixture that includes an absent column, a present-but-undefined, an empty string and a usr_system_admin prefix collision, and asserts the new write claims that id set exactly. The over-ceiling pin compares against the uncapped unowned set instead, because past 10 000 the old rule was itself lossy and the new one must beat it, not match it.

Clause ②: no

Re-derived from the current diff on the final commit:

$ git diff -U0 origin/main...HEAD | grep -E '^\+\s*export '
$ echo $?
1

No new exported symbol. grep export cannot see a changed signature on an already-exported declaration, so that was checked separately: claimSeedOwnership is the file's only export, and its signature is byte-identical to origin/main's — (ql: any, adminUserId: string, options: ClaimOwnershipOptions = {}): Promise SUMMARY_ARRAY, where the summary element type { object: string; count: number } is also unchanged. CLAIM_PAGE_ROWS, MAX_CLAIM_PAGES, UNOWNED_PREDICATES, ObjectWriter, claimPredicate, isPerRowHookBudgetRefusal, affectedRowCount and idsFrom are all module-private. No carrier is owed.

One accept-set note that is not an export: the typeof ql.find !== 'function' precondition is retained, because the paged fallback reads.

Not done, deliberately

Disposition 3 (a boot-phase predicate) is out of scope and not this seat's to authorise; the hooks are not made to coalesce across writes; packages/spec is not touched.

⚠️packages/cli's run-dev-unbuilt-workspace.e2e.test.ts is a known repo-wide flake (#14648 / #14727, domain:cli is on it). It is unrelated to this branch and is neither fixed nor skipped here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

os-salesand others added 4 commits September 2, 2026 19:25
…per unowned shape
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
`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
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

10 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

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 dbf115284295b1989d4648dbfbd7e5f3f96357dcpackageMentionDocs.

Which tree this was computed on

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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI red on 9f17ab8a0 — the tracked run-dev-unbuilt-workspace flake, not this PR's

Test Core (1/6), read from the job log:

::error file=packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts,
line=317: AssertionError: expected 'SIGKILL' to be null
Duration 945.51s (import 373.97s, tests 2420.53s)

Not this PR's. The diff is three files, all in @objectstack/plugin-security (claim-seed-ownership.ts, its test, one changeset) — it touches nothing in @objectstack/cli, nothing the CLI boots, and no shared script that test loads. The failing test is the tracked flake anchored at #14648 (priority:p1, domain:cli, dispatched, dev on it since 18:15Z), and the asserted value is the harness's own kill signal firing at its 40 s cap — a cap derived from a measured ~22 s uncontended run, against a shard that here spent 2420 s of test time. A stopwatch, not a behaviour.

This is the fourth PR from this seat that the same signature has taken (#14528 and #14687 in the merge queue, #14712 and now this one on branch CI).

No re-run spent and nothing pushed for it. Patch round 1 is in flight on this branch — the seat's review is at 14530#issuecomment-5516113880 and requires the predicate write to be paged so objects above MAX_BULK_PER_ROW_HOOK_ROWS are still claimed — so the head moves shortly and CI re-runs on its own.

@objectstack/plugin-security's own suite was green on this head: Test Files 95 passed (95) · Tests 1791 passed (1791).


Generated by Claude Code

…cts 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
…ema.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
@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Blocker record — this PR's own gates, and what is queued behind them

Putting this on the PR because the seat's instructions to the dev agent went out over a channel that did not survive: the container running this session restarted at ~21:45Z and killed the agent working on this branch. Nothing was lost from the branch (its head 0418ccf8f was already pushed), but the in-flight instructions died with the agent, so they are restated here where they persist.

Gate 1 — check-tenant-audit-census, red at 39126dc02, addressed at 0418ccf8f

census : | packages/plugins/plugin-security/src/claim-seed-ownership.ts | update | objectName | undecidable | elevated | 1 |
Fix: node scripts/tenant-audit-census.mjs --write
✗ check-tenant-audit-census self-test: 5 of 19 case(s) failed.

The seat's ruling on it, restated: ⛔ do not run --write and call it done.objectName going undecidable is a signal, not a chore — before the paging change that write site's object name was statically decidable, and recording the downgrade trades the census's proving power for a green light. The right first question is whether the write site can stay decidable. The commit 0418ccf8f ("bind the seed-ownership engine calls at the schema.name call site") is exactly that answer, and it is the right one.

The self-test: 5 of 19 half is a separate question and must not be assumed to travel with it: read the full job output, and for each of the five decide whether this diff falsified its premise or whether it is red on origin/main too — that has to be measured on origin/main, never inferred. If it is red there, it is not this PR's.

⚠️ Note the gate's own words: "selfTest() returned without reaching its verdict, so no success line was printed." By this repo's standing rule, no verdict line means it did not pass — it cannot be read as green.

Gate 2 — check-engine-double-contract, red now at 0418ccf8f

x RETAINED [update]: packages/plugins/plugin-security/src/claim-seed-ownership.test.ts now pins 5 engine double(s), ledger records 1.
Coverage grew, which is the direction this ledger wants — run
`node scripts/check-engine-double-contract.mjs --write` and commit so the new double is ratcheted too.
check-engine-double-contract: 1 problem(s).

This one is the mechanical case: the gate says coverage grew in the direction the ledger wants, and ratcheting is the prescribed fix. Unlike gate 1, there is no downgrade being recorded.

⚠️But scripts/engine-double-contract.pinned.json is three-way contended right now — this PR, #14528 and #14726 all write it. So the ratchet is not a standalone --write: merge origin/main first, commit the merge, then regenerate on the merged tree and let check:engine-double-contract be the proof. ⛔ Never hand-edit the ledger.

(The two unrecognised [findOne] lines name other files and are not counted in the gate's single problem; they need no action here.)

What is not blocked

The paging work itself measured well and is not in question: 5000 rows went from 10 658 ms / 5000 engine writes to 448 ms / 2 writes, the cap / trailing-batch branch was measured to engage over-cap with zero change to plugin-sharing, and the P2 premise came back false and was handled by measuring both worlds with the per-object skip notice as the discriminator. The seat's review is at 14530#issuecomment-5516113880; the one substantive change still owed there is paging the predicate write so objects above MAX_BULK_PER_ROW_HOOK_ROWS are claimed rather than refused whole.

Queue position, stated so the wait is a record

This seat is at the maintainer's dispatch cap of 3 running dev agents (two post-restart recoveries plus PR #14528's merge round). This PR's remaining work is next after PR #14726's patch round. ⛔ No manual re-run is being spent on the run-dev-unbuilt-workspace flake that also hits this branch — that is #14648's, and it is already dispatched in domain:cli.


Generated by Claude Code

…ership 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
… 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
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.

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

2 participants

@os-sales@claude