Repository navigation
Commit ff16740
fix(metadata-protocol): give each organization its own row identity on a per-organization seed replay (#21688)
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](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 1db5322 commit ff16740
3 files changed
Lines changed: 501 additions & 5 deletions
File tree
- .changeset
- packages
- metadata-protocol/src
- runtime/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| 15 | + | |
15 | 16 | | |
16 | 17 | | |
17 | 18 | | |
| |||
347 | 348 | | |
348 | 349 | | |
349 | 350 | | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
350 | 373 | | |
351 | 374 | | |
352 | 375 | | |
| |||
369 | 392 | | |
370 | 393 | | |
371 | 394 | | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
372 | 400 | | |
373 | 401 | | |
374 | 402 | | |
| |||
463 | 491 | | |
464 | 492 | | |
465 | 493 | | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
466 | 517 | | |
467 | 518 | | |
468 | 519 | | |
| |||
560 | 611 | | |
561 | 612 | | |
562 | 613 | | |
| 614 | + | |
| 615 | + | |
| 616 | + | |
563 | 617 | | |
564 | 618 | | |
565 | 619 | | |
| |||
865 | 919 | | |
866 | 920 | | |
867 | 921 | | |
| 922 | + | |
| 923 | + | |
| 924 | + | |
| 925 | + | |
| 926 | + | |
| 927 | + | |
| 928 | + | |
| 929 | + | |
| 930 | + | |
| 931 | + | |
| 932 | + | |
| 933 | + | |
| 934 | + | |
| 935 | + | |
| 936 | + | |
| 937 | + | |
| 938 | + | |
| 939 | + | |
| 940 | + | |
| 941 | + | |
| 942 | + | |
| 943 | + | |
868 | 944 | | |
869 | 945 | | |
870 | 946 | | |
| |||
901 | 977 | | |
902 | 978 | | |
903 | 979 | | |
| 980 | + | |
| 981 | + | |
| 982 | + | |
| 983 | + | |
| 984 | + | |
| 985 | + | |
| 986 | + | |
| 987 | + | |
| 988 | + | |
| 989 | + | |
| 990 | + | |
| 991 | + | |
| 992 | + | |
| 993 | + | |
| 994 | + | |
| 995 | + | |
| 996 | + | |
| 997 | + | |
904 | 998 | | |
905 | 999 | | |
906 | 1000 | | |
| |||
990 | 1084 | | |
991 | 1085 | | |
992 | 1086 | | |
993 | | - | |
| 1087 | + | |
994 | 1088 | | |
995 | 1089 | | |
996 | 1090 | | |
| |||
1122 | 1216 | | |
1123 | 1217 | | |
1124 | 1218 | | |
| 1219 | + | |
| 1220 | + | |
| 1221 | + | |
| 1222 | + | |
| 1223 | + | |
| 1224 | + | |
| 1225 | + | |
| 1226 | + | |
| 1227 | + | |
| 1228 | + | |
| 1229 | + | |
1125 | 1230 | | |
1126 | 1231 | | |
1127 | 1232 | | |
| |||
1476 | 1581 | | |
1477 | 1582 | | |
1478 | 1583 | | |
1479 | | - | |
| 1584 | + | |
1480 | 1585 | | |
1481 | 1586 | | |
1482 | 1587 | | |
| |||
1487 | 1592 | | |
1488 | 1593 | | |
1489 | 1594 | | |
1490 | | - | |
| 1595 | + | |
1491 | 1596 | | |
1492 | 1597 | | |
1493 | 1598 | | |
| |||
1510 | 1615 | | |
1511 | 1616 | | |
1512 | 1617 | | |
1513 | | - | |
| 1618 | + | |
1514 | 1619 | | |
1515 | 1620 | | |
1516 | 1621 | | |
| |||
1522 | 1627 | | |
1523 | 1628 | | |
1524 | 1629 | | |
1525 | | - | |
| 1630 | + | |
1526 | 1631 | | |
1527 | 1632 | | |
1528 | 1633 | | |
| |||
1703 | 1808 | | |
1704 | 1809 | | |
1705 | 1810 | | |
| 1811 | + | |
| 1812 | + | |
| 1813 | + | |
| 1814 | + | |
| 1815 | + | |
| 1816 | + | |
| 1817 | + | |
| 1818 | + | |
| 1819 | + | |
1706 | 1820 | | |
1707 | 1821 | | |
1708 | 1822 | | |
| |||
2736 | 2850 | | |
2737 | 2851 | | |
2738 | 2852 | | |
| 2853 | + | |
| 2854 | + | |
| 2855 | + | |
| 2856 | + | |
| 2857 | + | |
| 2858 | + | |
| 2859 | + | |
| 2860 | + | |
| 2861 | + | |
| 2862 | + | |
| 2863 | + | |
| 2864 | + | |
| 2865 | + | |
| 2866 | + | |
| 2867 | + | |
| 2868 | + | |
| 2869 | + | |
| 2870 | + | |
| 2871 | + | |
| 2872 | + | |
| 2873 | + | |
| 2874 | + | |
| 2875 | + | |
| 2876 | + | |
| 2877 | + | |
| 2878 | + | |
| 2879 | + | |
| 2880 | + | |
| 2881 | + | |
| 2882 | + | |
| 2883 | + | |
| 2884 | + | |
| 2885 | + | |
| 2886 | + | |
| 2887 | + | |
| 2888 | + | |
| 2889 | + | |
| 2890 | + | |
| 2891 | + | |
| 2892 | + | |
| 2893 | + | |
| 2894 | + | |
| 2895 | + | |
| 2896 | + | |
| 2897 | + | |
| 2898 | + | |
| 2899 | + | |
| 2900 | + | |
| 2901 | + | |
| 2902 | + | |
| 2903 | + | |
| 2904 | + | |
| 2905 | + | |
| 2906 | + | |
| 2907 | + | |
| 2908 | + | |
| 2909 | + | |
| 2910 | + | |
| 2911 | + | |
| 2912 | + | |
| 2913 | + | |
| 2914 | + | |
| 2915 | + | |
| 2916 | + | |
| 2917 | + | |
| 2918 | + | |
| 2919 | + | |
| 2920 | + | |
| 2921 | + | |
| 2922 | + | |
| 2923 | + | |
| 2924 | + | |
| 2925 | + | |
| 2926 | + | |
| 2927 | + | |
| 2928 | + | |
| 2929 | + | |
| 2930 | + | |
| 2931 | + | |
2739 | 2932 | | |
2740 | 2933 | | |
2741 | 2934 | | |
| |||
0 commit comments