Blocked-by: #4636
Found while implementing #4636 PR1 (write-path ownership key). Unassigned — nobody is on this. Recording only, per Prime Directive #10. Not fixed there: it is a SysMetadataRepository scoping defect, outside that PR's ruled scope, and it changes the repository write path — it deserves its own pin tests and review.
Evidence
SysMetadataRepository.restoreVersion (packages/metadata-protocol/src/sys-metadata-repository.ts, ~729) reads the current active row without a package predicate, then re-puts the historical body with no packageId option:
const currentActive = await this.get(ref, { state: 'active' }); // "match any package"
return this.put(ref, body, {
parentVersion: currentActive?.hash ?? null,
...
}); // no packageId
put then scopes its own existing-row lookup with:
where: this.whereFor(ref, state, opts.packageId ?? null)
and whereFor treats null as a predicate, not as "any package":
if (packageId !== undefined) where.package_id = packageId; // string -> eq; null -> IS NULL
undefined ?? null is null, so the lookup is always narrowed to package_id IS NULL. For a row bound to a real package (app.myapp, …) it therefore finds nothing:
existingHash reads null;
opts.parentVersion is the hash the unscoped read just returned;
null !== hash -> ConflictError.
Observed, driving the real protocol + a sys_metadata / sys_metadata_history double (objectstack#4636 branch claude/issue-4636-writepath-real-package-id, packages/objectql/src/protocol-writepath-object-ownership.test.ts):
[metadata_conflict] object/myapp_invoice advanced during rollback.
Expected parent sha256:00ca6e72c421ecc68fa6a5153ef2b962ea307d6c9f78646425666a0c885927bc but current is null.
The same row rolls back fine when it has no package binding, which isolates the package dimension as the cause.
Why it matters
Every rollback of an overlay row authored inside a Studio package workspace fails, and fails with a message that blames a concurrent edit ("advanced during rollback") when nothing changed. Two callers, both user-facing:
rollbackMetaItem — the per-item version-history revert;
revertCommit (protocol.ts, ~9986) — the package-commit revert, same restoreVersion call.
ADR-0070 pushes authoring toward always resolving a writable base package, so the share of rows carrying a real package_id grows over time; the package-less rows that still work are the legacy shape.
Note the second face of the same bug: if the parent check ever did pass (no active row at all), put's existing would still be null and it would insert a duplicate global row rather than update the package-bound one.
Suggested fix
Thread the binding through, rather than making a consumer tolerant: restoreVersion already reads the row it is restoring, so it can pass that row's package_id into put (and into its own get), matching what saveMetaItem does. Alternatively give put an explicit "any package" spelling distinct from null — today undefined means that, and ?? null erases it at the one call site that needed it. Whichever route, the ?? null in put is the line that turns "unspecified" into "unbound".
Needs a pin test at both callers (rollbackMetaItem and revertCommit) with a package-bound row. #4636 PR1 leaves a tripwire test asserting the current 409 ([tripwire] a package-bound rollback still 409s) precisely so this fix trips it: whoever lands the fix should replace that test with the assertion one line above it, which already states what the registry write-through must then do with the package ownership key.
Related: #4636 (the write-through whose package-bound limb this defect makes unreachable), ADR-0048 (the per-package overlay row), ADR-0070.
Generated by Claude Code
Blocked-by: #4636
Found while implementing #4636 PR1 (write-path ownership key). Unassigned — nobody is on this. Recording only, per Prime Directive #10. Not fixed there: it is a
SysMetadataRepositoryscoping defect, outside that PR's ruled scope, and it changes the repository write path — it deserves its own pin tests and review.Evidence
SysMetadataRepository.restoreVersion(packages/metadata-protocol/src/sys-metadata-repository.ts, ~729) reads the current active row without a package predicate, then re-puts the historical body with nopackageIdoption:putthen scopes its own existing-row lookup with:and
whereFortreatsnullas a predicate, not as "any package":undefined ?? nullisnull, so the lookup is always narrowed topackage_id IS NULL. For a row bound to a real package (app.myapp, …) it therefore finds nothing:existingHashreadsnull;opts.parentVersionis the hash the unscoped read just returned;null !== hash->ConflictError.Observed, driving the real protocol + a
sys_metadata/sys_metadata_historydouble (objectstack#4636 branchclaude/issue-4636-writepath-real-package-id,packages/objectql/src/protocol-writepath-object-ownership.test.ts):The same row rolls back fine when it has no package binding, which isolates the package dimension as the cause.
Why it matters
Every rollback of an overlay row authored inside a Studio package workspace fails, and fails with a message that blames a concurrent edit ("advanced during rollback") when nothing changed. Two callers, both user-facing:
rollbackMetaItem— the per-item version-history revert;revertCommit(protocol.ts, ~9986) — the package-commit revert, samerestoreVersioncall.ADR-0070 pushes authoring toward always resolving a writable base package, so the share of rows carrying a real
package_idgrows over time; the package-less rows that still work are the legacy shape.Note the second face of the same bug: if the parent check ever did pass (no active row at all),
put'sexistingwould still benulland it would insert a duplicate global row rather than update the package-bound one.Suggested fix
Thread the binding through, rather than making a consumer tolerant:
restoreVersionalready reads the row it is restoring, so it can pass that row'spackage_idintoput(and into its ownget), matching whatsaveMetaItemdoes. Alternatively giveputan explicit "any package" spelling distinct fromnull— todayundefinedmeans that, and?? nullerases it at the one call site that needed it. Whichever route, the?? nullinputis the line that turns "unspecified" into "unbound".Needs a pin test at both callers (
rollbackMetaItemandrevertCommit) with a package-bound row. #4636 PR1 leaves a tripwire test asserting the current 409 ([tripwire] a package-bound rollback still 409s) precisely so this fix trips it: whoever lands the fix should replace that test with the assertion one line above it, which already states what the registry write-through must then do with the package ownership key.Related: #4636 (the write-through whose package-bound limb this defect makes unreachable), ADR-0048 (the per-package overlay row), ADR-0070.
Generated by Claude Code