fix(metadata-protocol): give each organization its own row identity on a per-organization seed replay - #21688
Conversation
Real replayer (AppPlugin on a walled posture), real SeedLoaderService, ObjectQL and SqlDriver over better-sqlite3: two organizations each hold the full sys_business_unit set with zero SeedLoader errors, parent links resolve inside each organization, and the first organization keeps its authored ids. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…n a per-organization seed replay A per-organization replay re-inserted every authored seed id verbatim, so from the second organization on each fixed-id row collided on the global primary key and the references into those rows stayed unresolved. The replay now keeps the authored id while no row holds it (the first organization is unchanged), otherwise gives the row an id derived from the organization, and re-points this replay's references that name the authored id to the id the row landed with. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…eplay identity Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…ed-replay-per-org-ids
Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 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 04f09c87a369e4ba5f334f976d37c7ce7f3731e3 && git checkout 04f09c87a369e4ba5f334f976d37c7ce7f3731e3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 73d2c4fdb48dbdde7666ae7dd4f2ef792c4f86fe && git checkout -B drift-repro 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 && git merge --no-ff 73d2c4fdb48dbdde7666ae7dd4f2ef792c4f86fe
node scripts/docs-audit/affected-docs.mjs --json 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8
|
ACCEPT — PR #21688 at head
|
Fixes #21665
Clause-②: no
What this changes
On a walled deployment, every organization created after the first used to start without the app's fixed-id seed rows. A per-organization replay re-inserted each authored
idverbatim, a primary key is global, so the second organization's inserts were refused as duplicates. The references into those rows then stayed unresolved: 9[SeedLoader]errors per organization for the showcase'ssys_business_unittree.SeedLoaderService(packages/metadata-protocol/src/seed-loader.ts) now gives each organization its own row identity on a per-organization load (config.organizationId):idgets the id this organization's row already has, whether that is the authored id or the derived one. A second replay into the same organization therefore finds its own row and does not insert another.Producer location: as the claim expected, the replayer itself. No showcase seed change was needed, and there is no second copy of the seeds per organization.
The rule sits in
load(), so every load that names an organization follows it: the per-organization replayer, and package apply, draft publish and marketplace install into an organization. Boot seeding without an organization, dry runs and rows without an authoredidtake the code path they took before.Census (triage: census first)
examples/**, objectstack at72f3c74d60(re-read at merge base7d0781482d)examples/app-showcase/src/data/seed/index.ts,sys_business_unit(tenant-scoped:scripts/platform-object-tenancy-census.jsonreachin, tenant fieldorganization_id), 5 rowsbu_acme,bu_field_ops,bu_west_coast,bu_east_coast,bu_hq_finance,externalId: 'id'. app-crm, app-todo, app-multi-package, embed-objectql: 0parent_business_unit_idlinks in the same dataset. Declared metadata, outside the replay:examples/app-showcase/src/security/sharing-rules.ts:78,sharedWith: { type: 'unit_and_subordinates', value: 'bu_field_ops' }(see Acceptance notes). By name, not id:permission-sets.ts:401adminScope.businessUnit: 'Field Operations'. Comments only:approver-bindings.flow.ts:85,permission-sets.ts:379. Tests on the single posture (path unchanged):packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts:63,82-85,showcase-declarative-rbac-seeding.dogfood.test.ts:424054ec2680(shallow read-only clone, deleted after)src/*/data/*.seed.ts) key by natural or composite keys, and there are zeroid:keys in seed recordspackages/**, non-test)SeedSchemadataset ships from a platform packageStop condition (ADR-0131 and the seed contract): not met
Neither source declares a fixed seed id as a cross-organization identity. Both say the opposite:
sys_business_unitis the organization's business unit". The group-posture list, item 12, says "Seeds undergroupmust name their organization". No clause makes a seed row id span organizations.packages/spec/src/data/seed.zod.ts(externalId): "Standard: 'id' is rarely used for portable seed data — prefer natural keys."packages/spec/src/data/seed-loader.zod.ts(organizationId): per-tenant replay gives "every new tenant a private copy", and the scoped lookup exists "soupsertmode finds the per-org copy rather than another tenant's row".skills/objectstack-data/references/seeds.md:70: "Never useid".The only text that treats
bu_field_opsas a static, cross-organization handle is the showcase's own comment on its sharing rule. That is app convention, not ADR or contract.Mechanism hypotheses, as measured
72f3c74d60, an authoredidwent toengine.insertverbatim. The existing-row pre-load is scoped to the organization, so the second organization saw no row and inserted the same ids. On a realSqlDriverthe result wasUNIQUE constraint failed: sys_business_unit.id, with exactly 9 errors in the second organization's replay.parent_business_unit_id: 'bu_acme'), and the dataset'sexternalIdisid. "Keyed by externalId within the organization" therefore needs an id the replay can find again. A minted random id would make every later replay into that organization insert the set again. So the assigned id is derived (authored id +__+ organization id). It is deterministic per organization, and for one authored id two organizations never get the same id. The replay stays idempotent. Re-pointing covers only rows this replay re-identified, and only once they landed.bu_field_opsby row id, working where it works today. If every organization got derived ids, a fresh walled boot's first organization would differ from today's, and that rule would resolve nowhere at all.tenancyservice answeringisolatedmakes AppPlugin skip the inline seed and register its realseed-replayer, which the organizations runtime calls per new organization. No repeatable fixture beyond the test is needed, so no fixture card is asked for.Pins
packages/runtime/src/seed-replay-per-organization-identity.integration.test.tsuses the real replayer from AppPlugin on a walled posture, the realSeedLoaderService,ObjectQL, andSqlDriverover better-sqlite3, and asserts on stored rows:[SeedLoader]errors.None of these is a refusal pin (all are positive outcomes), so the ADR-0112
code+statusclause has no subject here.Reverse verification (fix committed first)
The fix was committed at
2a984c9878, and the mutation ran from that committed state.scripts/ablation-replace.mjsreplaced the anchorconst replayIds = config.organizationId && !config.dryRunwith a never-true guard carrying the markerABLATION_21665_FIXED_IDS_REUSED, so fixed ids were reused. The anchor count went 1 → 0, and the blob changed42cac258d456→1395cd65f51c. Then the package was rebuilt, andablation-dist-preflightfound the marker in 2 built files.Tests 4 failed | 2 passed (6). The red ones were pin 1, pin 2, the re-replay case and the UUID case. The green ones were pin 3 and the walled-inline precondition. The log held 32UNIQUE constraint failed: sys_business_unit.idlines.git checkout HEAD -- packages/metadata-protocol/src/seed-loader.ts. The blob matched HEAD (42cac258d456),git diff HEADwas 0 bytes, andgit status --porcelainshowed 0 lines. After a rebuild,--absentconfirmed the marker was gone from all 24 built files. The rerun gaveTests 6 passed (6).ablation-replacerefused it (anchor count 1 → 1) and restored the file itself. Nothing ran on that attempt.Tests
All readings below were taken at
73d2c4fdb4, which is the final commit, afterorigin/main(7d0781482d) was merged in.72f3c74d60, before any edit): pins 1 and 2, the re-replay case and the UUID case were red (Tests 4 failed | 2 passed (6)), and the log heldUNIQUE constraint failed: sys_business_unit.id.pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2:Test Files 209 passed | 3 skipped (212),Tests 3463 passed | 19 skipped (3482).pnpm --filter @objectstack/metadata-protocol typecheck: exit 0.pnpm --filter @objectstack/runtime typecheck: exit 0. The test layer compiles undertsconfig.test.json, andtest-typecheck-debt.jsonis held.app-plugin.*seed*,seed-*,domains/packages-seed-apply-*):Test Files 17 passed (17),Tests 159 passed (159).2a984c9878(unchanged since, apart from a type annotation): objectqlseed-loader-org-fallback.test.tspassed 5/5. cloud-connectionmarketplace-install-local-{seed-replayer,heal,tenancy-posture}.test.tspassed 30/30.turbo ls --affectedwas not used. Consumers that import@objectstack/metadata-protocoltake no public-surface change (no export, signature or schema moved), so their full suites are left to CI.node scripts/pm/dispatch-gates.mjs --commands(no paths), re-derived at this commit, gave 62 commands. All 62 exited 0.--ranreconciliation: "62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN", with exit codes recorded.check:dual-build-cjs-loadsneeds every package built, so it ran after a fullturbo run build("106 published require entry point(s) across 66 package(s) load").check-closing-target-claim,check-partof-closing-keyword,check-single-claim-paths). They are re-run against this PR and reported in theos-dev-reportcomment on the card. The roster includescheck:error-status-conformance("every derivable runtime status is documented, and every documented status is reachable") andcheck:engine-double-contract(OK, 936 pinned).check:query-options-erasuresaw the new id probe erase its query options (as any, 3 → 4 grandfathered sites). It is now a typedEngineQueryOptionslocal, and the ratchet "holds: 67 unswept non-test site(s) in 17 file(s), none new".Acceptance notes
examples/app-showcase/src/security/sharing-rules.ts:78usesunit_and_subordinateswithvalue: 'bu_field_ops'. On a walled deployment it resolves only in the organization holding that id, which is the first one. Later organizations now hold their own Field Operations unit under a derived id. The rule does not reach it there. Reading the code,warnOnEmptyUnitExpansionis the branch that reports that empty expansion; I did not measure it firing. This is not a regression: before this PR those organizations had no units at all. It is a question about how declared, cross-tenant metadata should name organization-owned rows, and it is outside this card. Noted only (carrier: none).driver-memorylands a second row under an existing primary key. I measured this directly on the driver: twocreate('probe_obj', { id: 'fixed_1' })calls gave 2 rows with that id, with no refusal. That is why this defect class is invisible on memory-driver suites, and why the pins useSqlDriver. No public-door reach was measured, so it is noted, not filed (carrier: none).@objectstack/organizations): this claim did not need a repeatable walled boot, so no fixture card is requested.Generated by Claude Code