Skip to content

fix(metadata-protocol): give each organization its own row identity on a per-organization seed replay - #21688

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21665-seed-replay-per-org-ids
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21665-seed-replay-per-org-ids

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 id verbatim, 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's sys_business_unit tree.

SeedLoaderService (packages/metadata-protocol/src/seed-loader.ts) now gives each organization its own row identity on a per-organization load (config.organizationId):

  1. A row authored with an id gets 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.
  2. Otherwise it gets the authored id, while no row anywhere holds it. The first organization a seed is replayed into keeps exactly the authored ids.
  3. Otherwise it gets an id derived from the authored id and the organization. The authored id belongs to another organization's row (or an organization-less one) and is never reused.
  4. A reference in the same replay that names an authored id of a re-identified row resolves to the id that row landed with, in pass 1 and in pass 2. The lookup runs before the internal-id short-circuit, so a UUID-shaped authored id is re-pointed too. Before, it was kept verbatim and would have linked the row to another organization's row.

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 authored id take the code path they took before.

Census (triage: census first)

Tree Fixed-id rows on tenant-scoped objects References into them
examples/**, objectstack at 72f3c74d60 (re-read at merge base 7d0781482d) 1 population: examples/app-showcase/src/data/seed/index.ts, sys_business_unit (tenant-scoped: scripts/platform-object-tenancy-census.json reach in, tenant field organization_id), 5 rows bu_acme, bu_field_ops, bu_west_coast, bu_east_coast, bu_hq_finance, externalId: 'id'. app-crm, app-todo, app-multi-package, embed-objectql: 0 In-replay (re-pointed by this PR): 4 parent_business_unit_id links 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:401 adminScope.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:42
hotcrm at 4054ec2680 (shallow read-only clone, deleted after) 0. All 9 seed files (src/*/data/*.seed.ts) key by natural or composite keys, and there are zero id: keys in seed records none
The platform's own packages (packages/**, non-test) 0. No SeedSchema dataset ships from a platform package none

Stop 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:

  • ADR-0131 §D3: "a seeded sys_business_unit is the organization's business unit". The group-posture list, item 12, says "Seeds under group must 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 "so upsert mode finds the per-org copy rather than another tenant's row".
  • Published guidance, skills/objectstack-data/references/seeds.md:70: "Never use id".

The only text that treats bu_field_ops as 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

  • H1, confirmed. At 72f3c74d60, an authored id went to engine.insert verbatim. The existing-row pre-load is scoped to the organization, so the second organization saw no row and inserted the same ids. On a real SqlDriver the result was UNIQUE constraint failed: sys_business_unit.id, with exactly 9 errors in the second organization's replay.
  • H2, confirmed, with one refinement. References are raw authored ids (parent_business_unit_id: 'bu_acme'), and the dataset's externalId is id. "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.
  • H3: the first organization keeps the authored ids. That is what keeps it byte-identical. It is also what keeps the showcase's sharing rule, which names bu_field_ops by 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.
  • H4. See the census above.
  • H5. See the stop condition above.
  • H6. The walled boot is built in-test. A tenancy service answering isolated makes AppPlugin skip the inline seed and register its real seed-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.ts uses the real replayer from AppPlugin on a walled posture, the real SeedLoaderService, ObjectQL, and SqlDriver over better-sqlite3, and asserts on stored rows:

  1. Two organizations each hold the full seeded set, with zero [SeedLoader] errors.
  2. The parent references resolve inside each organization, and no south row or link names a north id.
  3. The first organization is unchanged. It keeps the authored ids and parent links, and its rows deep-equal their snapshot after the second organization's replay runs twice.
  • A second replay into the same organization inserts 0 rows, and its ids are stable.
  • A UUID-shaped authored id is re-pointed, so the second organization never links to the first one's row.

None of these is a refusal pin (all are positive outcomes), so the ADR-0112 code + status clause 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.mjs replaced the anchor const replayIds = config.organizationId && !config.dryRun with a never-true guard carrying the marker ABLATION_21665_FIXED_IDS_REUSED, so fixed ids were reused. The anchor count went 1 → 0, and the blob changed 42cac258d456 → 1395cd65f51c. Then the package was rebuilt, and ablation-dist-preflight found the marker in 2 built files.

  • Mutated: 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 32 UNIQUE constraint failed: sys_business_unit.id lines.
  • Restored with git checkout HEAD -- packages/metadata-protocol/src/seed-loader.ts. The blob matched HEAD (42cac258d456), git diff HEAD was 0 bytes, and git status --porcelain showed 0 lines. After a rebuild, --absent confirmed the marker was gone from all 24 built files. The rerun gave Tests 6 passed (6).
  • First attempt, reported honestly: my first replacement spelling contained the anchor, so ablation-replace refused 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, after origin/main (7d0781482d) was merged in.

  • Reproduced on the base first (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 held UNIQUE 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 under tsconfig.test.json, and test-typecheck-debt.json is held.
  • Runtime, the new pin file plus all 16 seed-related suites (app-plugin.*seed*, seed-*, domains/packages-seed-apply-*): Test Files 17 passed (17), Tests 159 passed (159).
  • Other suites that drive the real loader, run at 2a984c9878 (unchanged since, apart from a type annotation): objectql seed-loader-org-fallback.test.ts passed 5/5. cloud-connection marketplace-install-local-{seed-replayer,heal,tenancy-posture}.test.ts passed 30/30.
  • Scope: turbo ls --affected was not used. Consumers that import @objectstack/metadata-protocol take no public-surface change (no export, signature or schema moved), so their full suites are left to CI.
  • Gates. node scripts/pm/dispatch-gates.mjs --commands (no paths), re-derived at this commit, gave 62 commands. All 62 exited 0. --ran reconciliation: "62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN", with exit codes recorded. check:dual-build-cjs-loads needs every package built, so it ran after a full turbo run build ("106 published require entry point(s) across 66 package(s) load").
  • Artifact-roster block (55 commands printed outside the total): 52 exited 0. Three are PR-context gates that print NOT WIRED / NOT MEASURED without a pull request (check-closing-target-claim, check-partof-closing-keyword, check-single-claim-paths). They are re-run against this PR and reported in the os-dev-report comment on the card. The roster includes check:error-status-conformance ("every derivable runtime status is documented, and every documented status is reachable") and check:engine-double-contract (OK, 936 pinned).
  • One real red was caught and fixed on the way: check:query-options-erasure saw the new id probe erase its query options (as any, 3 → 4 grandfathered sites). It is now a typed EngineQueryOptions local, and the ratchet "holds: 67 unswept non-test site(s) in 17 file(s), none new".

Acceptance notes

  • The declared sharing rule names a seeded row id. examples/app-showcase/src/security/sharing-rules.ts:78 uses unit_and_subordinates with value: '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, warnOnEmptyUnitExpansion is 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-memory lands a second row under an existing primary key. I measured this directly on the driver: two create('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 use SqlDriver. No public-door reach was measured, so it is noted, not filed (carrier: none).
  • Fixture gap (no example declares @objectstack/organizations): this claim did not need a repeatable walled boot, so no fixture card is requested.
  • The deterministic id format is an implementation detail and is not pinned. The tests assert properties instead: it differs from the authored id, it is stable across replays, and it is unique per organization.

Generated by Claude Code

claude added 5 commits October 4, 2026 04:42
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>
@github-actions github-actions Bot added the size/l label Oct 4, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 4, 2026
@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 8 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/validation.mdx (via SeedLoaderService (symbol, a top-level class))
  • content/docs/protocol/objectql/state-machine.mdx (via SeedLoaderService (symbol, a top-level class))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via SeedLoaderService (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 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 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 04f09c87a369e4ba5f334f976d37c7ce7f3731e3 — the merge of head 73d2c4fdb48dbdde7666ae7dd4f2ef792c4f86fe into base 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT — PR #21688 at head 73d2c4fdb4

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-04T05:56Z. The os-dev report is on #21665. Judged against GitHub and the branch, not against the report.

  • Shape: draft, base main, assignee os-project-manager.
    • The first lines are Fixes #21665 and Clause-②: no.
    • The closing-keyword scan finds #21665 only. The body references no other issue number.
  • Scope: 3 files, +501/-5: seed-loader.ts, one new runtime integration test and the changeset. NOT governed. No packages/spec file is touched.
  • The diff, read:
    • assignReplayRowIds runs only on a load with config.organizationId that is not a dry run. Boot seeding (including the single-organization fallback, which is not config.organizationId), dry runs and rows with no authored id are untouched.
    • The order of assignment is: this organization's own row (authored or derived id), then the authored id while no row holds it, then <authored>__<organizationId>. A re-replay is idempotent, and the first organization keeps the authored ids.
    • isRowIdHeld probes under { isSystem: true }. That context bypasses the organization wall (organizations-plugin.ts:308 returns next() for it, and claim-orphan-org-rows.ts:7 relies on the same bypass), so the probe sees every organization's rows. Only a missing table reads as "free". Any other read failure propagates, matching loadExistingRecords (Measured set: five read seams answer a failed read from an empty accumulator with no log and no field saying the answer is incomplete #8896).
    • replayIdByAuthoredId is filled only through noteLanded, at all five write and skip sites, and only once the row has landed. It is cleared per load.
    • resolveReferenceItem reads the map before the internal-id short-circuit. Pass 1 (:1379) and pass 2 (:1973) both go through it, so the changeset's "pass 1 and pass 2" and its UUID sentence hold.
  • Edge checked by the seat: an object replayed per organization that a deployment treats as global. The registry provisions organization_id on every object unless the object opts out, and the replay already stamps each row with the target organization. So a second organization's copy under a derived id is the replay's existing per-organization contract (seed-loader.zod.ts: "every new tenant a private copy"), not a new duplication. The census finds no fixed-id dataset outside the showcase's sys_business_unit.
  • Stop condition (ADR-0131 / seed contract): not met, checked on origin/main.
    • seed-loader.zod.ts organizationId: replay "give[s] every new tenant a private copy", and lookups are scoped to the per-organization copy.
    • skills/objectstack-data/references/seeds.md: "Never use id" as an externalId.
    • examples/app-showcase/src/data/seed/index.ts:251-256: externalId: 'id', with the five bu_* rows and their in-replay parent links.
  • Clause-②: no — accepted. No schema, export, accepted input or error code changes. Nothing on the contract surface widens or narrows, and there is no path leg, so no contract review is owed.
  • Changeset, checked sentence by sentence:
    • @objectstack/metadata-protocol patch. runtime changes only a test file, which it does not ship, so it has no entry.
    • The defect sentence matches H1 (UNIQUE constraint failed: sys_business_unit.id).
    • The keep-authored, derived, re-replay, reference and UUID bullets each match the code above.
    • "One info line per dataset it re-identified" matches the log, which is emitted only when the re-identified count is above zero.
    • "Every load that names an organization" holds by construction: the rule lives in the loader.
    • "Unchanged" for boot seeding without an organization, dry runs and rows without an authored id matches the guard.
  • Evidence:
    • Base repro at 72f3c74d60: 4 failed / 2 passed.
    • metadata-protocol suite: 3463 passed. runtime pin file plus 16 seed suites: 159 passed. Typecheck exits 0 for both packages.
    • Reverse verification with a dist preflight: exactly the four positive pins went red, the controls stayed green, and the restore was proved by blob equality and an --absent preflight.
    • The pins use SqlDriver. The dev measured that driver-memory lets the defect through, which is why.
    • No refusal pin exists, so the ADR-0112 code/status check has no subject.
  • Gates:
    • dispatch-gates --ran: 62 derived, 62 run, every one exit 0. check:dual-build-cjs-loads was measured after a full build.
    • The artifact-roster block exits 0, including check:error-status-conformance and the 3 PR-context guards after pr_create.
    • One real red was caught and fixed in-branch: the check:query-options-erasure ratchet.
  • Deviations — accepted. Six pushes under the single git push budget line, none forced. The consumer suites ran one commit early, and the delta since then is a type annotation plus a merge that touches neither package. The attribution follows AGENTS.md.
  • CI: read by the seat at landing. The seat lands only once every check is green or an expected skip.

Out-of-scope findings — Acceptance notes, not filed: both have no measured public-door reach.

  • The showcase sharing rule names the seeded row id bu_field_ops. On a walled deployment it now reaches only the first organization. Later organizations had no units before this change, so this is not a regression.
  • driver-memory accepts a duplicate primary key.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 4, 2026 06:25
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 4, 2026 06:25
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit ff16740 Oct 4, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21665-seed-replay-per-org-ids branch October 4, 2026 07:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants