Repository navigation
fix(plugin-security,organizations): the seed-ownership claims re-own only the rows the seed landed on (ADR-0138 D1) - #23605
Conversation
…s re-own only the rows the seed landed on Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude <noreply@anthropic.com>
… seeded-row readings Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude <noreply@anthropic.com>
…ss boots Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude <noreply@anthropic.com>
…s its seed replay reported Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude <noreply@anthropic.com>
…double ledger rows Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude <noreply@anthropic.com>
…ed-claim-seed-rows
…c/contracts to source Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 5 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 151 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5a2967aaf080e1579310eff933136f09c2775b32 && git checkout 5a2967aaf080e1579310eff933136f09c2775b32
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8d05456cbe03667ea2352c9a99e67d4a862a8294 2c1dd397bfc7c161eb1f110638e3ef73e6533c3d && git checkout -B drift-repro 8d05456cbe03667ea2352c9a99e67d4a862a8294 && git merge --no-ff 2c1dd397bfc7c161eb1f110638e3ef73e6533c3d
node scripts/docs-audit/affected-docs.mjs --json 8d05456cbe03667ea2352c9a99e67d4a862a8294
|
席位核对 — ACCEPT ·
|
Contract reviewServed-tier: Inputs: card #23354 (body and all ten comments, rulings and earlier records included); PR #23605 (body, the 31-file list, the net diff against its merge base ① Derived judgmentsFace and tier. The file list carries a published schema ( Every accept-set and public-surface change the diff implies, each named right or wrong:
Check-runs on this head, 44 in all at the reading above: 31 success, 8 skipped (path-filtered or opt-in), 0 failure, 5 in progress. Concluded and judged as the gate verdicts: Lint & Repo Gates and its lanes (which carry ② Semver level
③ Boundary flagsDev deviations (6184063982), each:
Open questions (the dev's two in 6184063982; the PR body's three):
Seed writers that emit no Out-of-scope findings: (a) is Q3. (b) Triage's acceptance added after this head (6184093292): "a runtime-created unowned row keeps Carrier note 6158485388 — APPLIED (①.9). Gap named, not blocking: the Docs drift advisory (6184053472): five hand-written pages are listed through the Governed surfaces: none in the file list, so Prime Directive #14's tiers do not bind this PR; this record is owed by the Implemented-by: VERDICT: PASS |
… results array SeedLoaderResultSchema requires results (one entry per dataset, empty when nothing loaded) and the real loader returns it on every path. The fake answered without it, a shape the producer never produces, so the replayer's hand-off of result.results to the seed-settlement tracker threw on it. Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #23354 (body and all twelve comments, rulings and earlier records included); PR #23605 (body, the 32-file list, and the net diff against its merge base ① Derived judgmentsFace and tier. The file list carries a published schema ( Every accept-set and public-surface change the diff implies, each named right or wrong:
Check-runs on this head, 39 at the reading above: 27 success, 5 skipped (path-filtered or opt-in: ② Semver level
③ Boundary flagsDev deviations (
Dev deviation ( Open questions (the dev's two in
Seed writers that emit no Out-of-scope findings: (a) is Q3. (b) Triage's added acceptance ( Carrier note Gap named, not blocking: the Docs drift advisory ( Governed surfaces: none in the file list, so Prime Directive #14's tiers do not bind this PR; this record is owed by the Implemented-by: VERDICT: PASS |
Fixes #23354
Clause-②: yes
What this changes
The seed-ownership claim hands seeded business records to the platform admin. It runs on every
app:seeded, so on every boot of an app with seed data. Its NULL-owner arm re-owned every unowned row of every eligible object. No stored column tells a seed row from a guest-written row or a public form's intake, and ADR-0138 D1 says those keep no owner ("the owner stays unset"). So the claim handed them to the platform admin at the next boot.This takes triage's route, option A (
6182011415). The seed loader already records the row every record landed on. It now publishes those rows, and both claims re-own only rows that are both unowned and seeded.@objectstack/spec(minor)SeedLoadResultSchemagainslandedIds(z.array(z.string()).default([]), described). It holds the ids of the stored rows a dataset's records landed on: a row a record inserted, a row it updated, and a row its key matched on a replay and left as it was. Each id appears once, in record order. A dry run reports none.gen:schemaandgen:docswere regenerated:authorable-surface/data.json,authorable-defaults/data.jsonandcontent/docs/references/data/seed-loader.mdx.ISeedSettlementServicegainsseededRecordIds?(object, organizationId?).organizationId, it answers for this process's boot-time seed sources. A source publishes its rows before it settles, so a reader insideapp:seededsees them.organizationId, it answers for that organization's per-organization replay. The host keeps the most recent replay only (see open question 1).@objectstack/metadata-protocol(minor)SeedLoaderService.loadDatasetreturnslandedIdsfrominternalIdByRecordIndex, the map every write site already fills throughnoteLanded. That covers the insert flush, the self-reference write, the update path, the replay skip path and a refused update of a matched row. No resolution rule is copied anywhere.@objectstack/runtime(minor)seed-settlement.ts) implementsseededRecordIds.SeedSourceHandlegainspublishLanded(report), and the new module functionrecordOrganizationReplay(ctx, organizationId, report)records a per-organization replay. That function is not re-exported from the package entry.AppPluginpublishes the inline seed'sresult.resultsbeforeseedSource.settle(). The basic-insert fallback now keeps the ids of the rows it inserts and publishes them too. The per-organizationseed-replayerrecords each replay under its organization.@objectstack/plugin-security(patch)claimSeedOwnershipbuilds it fromoptions.seededRecordIdsasowner_id IS NULL AND id IN (chunk), one write per chunk ofCLAIM_PAGE_ROWSids, which is half the per-row hook ceiling. A chunk is never refused for its size and stays inside a driver's parameter limit.usr_systemarm is unchanged. No guest door, form door or runtime writer stamps that owner (git grep SystemUserId.SYSTEM), so only seed or legacy rows carry it.seededRecordIdsthe NULL arm re-owns nothing, and the pass report appends a sentence that says so.claimSeedOwnershipOnSettleandrunBootstrap(throughbootstrapPlatformAdmin's newseededRecordIdsoption) read the reading through the contract, withreadSeededRecordIds. The promotion pass runs in the same process as the boot's settle. A seed that settles after promotion (over budget) is claimed by theapp:seededre-run, as before.id INlist by its size, never inlined.6158485388is applied:scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin.ts.jsongains ADR-0138 with its D1 invariant.@objectstack/organizations(patch)claimOrgSeedOwnershiptakes the same selector.OrganizationsPluginbindsseededRecordIds(object, organizationId)for the organization the owner bind claims for. That organization's replay ran inside thesys_organizationinsert of the same call, through Middleware B.Each write carries at most
CLAIM_CHUNK_IDSids, which is half the ceiling. So no write can be refused for its size, and the page-read fallback from #23054 had nothing left to answer. It is removed rather than kept as unreachable code. The #23054 guarantee holds: every seeded row is claimed, however many there are, because every chunk is written.The deprecated
ensureDefaultOrganizationwrapper holds no kernel. So it has no reading and hands over no row, and its docblock says so.Where the landing went beyond the claim's file surface
Each of these was measured as necessary:
packages/runtime/src/app-plugin.ts: the replayer closure and the basic-insert fallback, beside the settle hand-off. The organizations twin needs the replay's rows, which the boot settle does not carry.packages/plugins/organizations/src/organizations-plugin.ts(the claim wiring) andensure-default-organization.ts(its docblock only).scripts/engine-double-contract.pinned.json: five rows recorded by--writefor the two new test files' engine doubles.Pins
claim-seed-ownership-seed-settle-rerun.test.ts(two boots, realSecurityPlugin);claim-seed-ownership-warm-boot.test.ts(realObjectQLoverSqlDriver, one SQLite file, two processes)claim-seed-ownership.test.ts, the org twin's unit test and both real-engine dispatch pins6181198317)Producer side:
seed-loader-landed-ids.test.ts, real loader: the insert path; a replay on the skip path and on the update path; a dataset keyed on a reference reporting the matched row whose stored key is the resolved id; an unseeded row never reported; a refused insert; two records resolving to one row; a dry run.seed-settlement.test.ts: the tracker.app-plugin.seed-landed.test.ts: on the loader path and on the fallback, the claim insideapp:seededreads exactly the seed's rows; a second boot re-reports the first boot's rows and not a guest row; the replayer records under its organization.Measured: what the
id INwrite costs (InMemoryDriver)The claim's docblock records that an
id INpredicate is quadratic onInMemoryDriver, where mingo hashes the list again for each row. The NULL arm now pays that shape on purpose, so it was measured.Rig: a real
ObjectQLoverInMemoryDriver, one object, one write, two runs each. The scratch rig was deleted after the reading.owner_id IS NULLowner_id IS NULL AND id IN (all)claimSeedOwnershipwith the readingSo the cost is paid once, on the pass that claims the rows, and only on the in-memory driver. On a warm boot
owner_id IS NULLfails first and theINlist is never consulted. SQL drivers were not measured here.Outside the claim's purpose (named, as triage asked)
The claim's purpose is handing the boot seed to the first admin. These seed writers emit no
app:seeded, publish no rows, and are no longer re-owned by a later boot's pass:runtime/src/domains/packages.ts);metadata-protocol/src/protocol.ts, untouched per the dispatch);app-plugin.ts);cloud-connection).A dataset they share with the boot bundle is re-reported by the next boot's replay and claimed then.
Open questions for the contract review
cloud-connectionregisters a fallbackseed-replayeronly when noAppPluginregistered one, and it does not record replays. On that composition the owner bind finds no reading and hands over nothing.Acceptance notes
ignorerows. A seed inignoremode reports a row its key matched without writing it. So a pre-existing row with the same natural key counts as the seed's. That follows the loader's matching rule, not this change.InMemoryDriver's$inis quadratic. It is a property of that driver, recorded in the claim docblock with the measurement above. Carrier: none.claimSeedOwnershipandclaimOrgSeedOwnershipare exported. WithoutseededRecordIdsthey no longer re-own NULL-owner rows. The changeset states it.Tests and gates
Head
70a9ae600. Every run below went throughscripts/pm/os-verify-lock.sh, and itsVERDICT command-exitline is quoted.Merge.
origin/main32e03d2fdwas merged in (090a6a0ef, clean). Then the closure was rebuilt (turbo run buildoforganizations…,plugin-security…,runtime…,cli…,cloud-connection…,platform-objects…: 62 tasks, exit 0), andpnpm --filter @objectstack/spec check:generatedexited 0 on the merged tree.Typecheck: exit 0 for all 8 packages.
spec,metadata-protocol,runtime,plugin-security,organizations,cli,cloud-connection,platform-objects.runtimeandorganizationswere run again at70a9ae600, exit 0.Whole package suites:
metadata-protocolplugin-securityorganizations(at70a9ae600)platform-objectsTargeted, at
70a9ae600.runtime:app-plugin.seed-landed.test.ts,seed-settlement.test.tsandapp-plugin.seed.test.ts(3 files, 34 tests passed).Declared narrowing, read from this PR's CI. These suites were not run locally:
runtime's andspec's whole suites,cli(--project unit),cloud-connection, and anytest:repoproject. The reason is lock pressure on the shared box (PM notes, 03:11Z and 03:40Z).cliandcloud-connectionimport the changed types only, and both typecheck clean.Gates.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 125 families, each run with its exit code captured before any pipe. Reconciliation with--ran: 125 accounted for, 121 run, 4 NOT MEASURED, 0 UNRUN.Two gates were red on the first pass and are green at
70a9ae600:check:test-source-alias:organizationsnow aliases@objectstack/spec/contractsto source.check:where-matcher: the new runtime test double's matcher sits at module scope and refuses operators it does not implement.check:engine-double-contractrecorded the new doubles (--write, 5 rows).NOT MEASURED, each named:
check-plugin-teardown-shape --self-test: exit 3. A shallow clone cannot reach the fixture commit.spec check:skill-examples: exit 3. It needs aclient-reactbuild.check:dual-build-cjs-loads: exit 3. It needs every package built.check:type-check-debt: stopped. Its re-measure builds the whole tree under the shared lock, and it was withdrawn on the PM lock-pressure note.All four run in CI.
Ablation. Not run as a separate step. Pin (b) is red by construction against the old predicate: the unit double, the real-engine pins and the warm-boot file each assert that an unreported NULL-owner row keeps no owner, and the old
owner_id IS NULLwrite would re-own it.Generated by Claude Code