Measured while verifying #8858's premise (a different concern in the same method), at origin/main8664a2c99. Not fixed there: outside that card's declared region and a different defect class.
What was measured
publishPackageDrafts lists the package's drafts with repo.listDrafts({ packageId }) (sys-metadata-repository.ts:876 — filters package_id = :packageId), then promotes each through promoteDraftForPublish → repo.promoteDraft(ref, …). promoteDraft resolves the row it is about to promote with
constdraftRow=awaitthis.engine.findOne('sys_metadata',{where: this.whereFor(ref,'draft'),});and whereFor (sys-metadata-repository.ts:1385) omits the package_id dimension when the caller passes no packageId — deliberately, per its own comment: "the package dimension is left out so the query keeps its historical 'match any package' behaviour — which is what the RESOLVING reads want (delete/promote/restore each locate the one row whatever it is bound to)". ADR-0048 keys overlay rows by (org, type, name, package_id) precisely so that two installed packages shipping the same name each get their OWN row, so "the one row" is an assumption the key model does not guarantee.
Measured with the real SysMetadataRepository and the real protocol over a stub engine — two ordinary singular object/shared_ticket drafts, one bound to app.other, one to app.demo, then publishPackageDrafts({ packageId: 'app.demo' }):
response: { success: true, publishedCount: 1,
published: [{ type: 'object', name: 'shared_ticket' }] }
active row: { type: 'object', name: 'shared_ticket', package_id: 'app.other', label: 'FROM_OTHER' }
draft row: { type: 'object', name: 'shared_ticket', package_id: 'app.demo', label: 'FROM_DEMO' } ← still pending
audit row: { type: 'object', name: 'shared_ticket', outcome: 'allowed', code: 'ok' }
Publishing app.demo promoted app.other's pending draft to active, drained that package's draft row, recorded it under app.demo's ADR-0067 commit and its ADR-0010 audit row — and left app.demo's own edit pending, while answering success: true.
No legacy residue is involved: both rows are ordinary drafts a normal saveMetaItem(..., mode: 'draft') produces.
⚠️Which of the two rows wins is UNSPECIFIED, not "always the other package". The stub returns the first match in insertion order; a real driver's findOne has no defined order without an ORDER BY. The defect is that the query cannot distinguish the rows, not the particular winner — so on a real driver this is a coin toss, per publish.
Two adjacent shapes, also measured: a legacy plural-typed draft row (type = 'objects') bound to package A promotes the singular twin bound to package B, or a package-less singular twin — which is also how the batch's all-or-nothing NO_DRAFT abort for at-rest plural rows can be walked around.
Why it is a defect
- A publish of package A can silently publish package B's unreviewed pending change, attributed to A's commit id.
- A's own draft stays pending while the response reports the item as published: the author sees "published" and the environment does not have the edit.
- ADR-0048 exists so two packages shipping the same name stay separate; this lookup re-merges them at the one door that writes
active.
Suggested disposition
Resolve the draft under the same key the caller listed it by: thread the row's package_id from listDrafts through promoteDraftForPublish into promoteDraft, exactly as PromotedDraft.draftOrgId already threads the draft's own org scope for the analogous org-partition bug (#3115). whereFor already accepts a packageId argument (string → eq, null → IS NULL), so this is threading a value, not a new query shape. The same question should be asked of the other "match any package" resolving reads that comment names (delete / restore).
Related: #6215 (the same package-binding blind spot one verb over, in restoreVersion's parent lookup).
Backlink: #8858 (the measurement that surfaced this).
Measured while verifying #8858's premise (a different concern in the same method), at
origin/main8664a2c99. Not fixed there: outside that card's declared region and a different defect class.What was measured
publishPackageDraftslists the package's drafts withrepo.listDrafts({ packageId })(sys-metadata-repository.ts:876— filterspackage_id = :packageId), then promotes each throughpromoteDraftForPublish→repo.promoteDraft(ref, …).promoteDraftresolves the row it is about to promote withand
whereFor(sys-metadata-repository.ts:1385) omits thepackage_iddimension when the caller passes nopackageId— deliberately, per its own comment: "the package dimension is left out so the query keeps its historical 'match any package' behaviour — which is what the RESOLVING reads want (delete/promote/restore each locate the one row whatever it is bound to)". ADR-0048 keys overlay rows by(org, type, name, package_id)precisely so that two installed packages shipping the same name each get their OWN row, so "the one row" is an assumption the key model does not guarantee.Measured with the real
SysMetadataRepositoryand the real protocol over a stub engine — two ordinary singularobject/shared_ticketdrafts, one bound toapp.other, one toapp.demo, thenpublishPackageDrafts({ packageId: 'app.demo' }):Publishing
app.demopromoted app.other's pending draft to active, drained that package's draft row, recorded it underapp.demo's ADR-0067 commit and its ADR-0010 audit row — and leftapp.demo's own edit pending, while answeringsuccess: true.No legacy residue is involved: both rows are ordinary drafts a normal
saveMetaItem(..., mode: 'draft')produces.findOnehas no defined order without anORDER BY. The defect is that the query cannot distinguish the rows, not the particular winner — so on a real driver this is a coin toss, per publish.Two adjacent shapes, also measured: a legacy plural-typed draft row (
type = 'objects') bound to package A promotes the singular twin bound to package B, or a package-less singular twin — which is also how the batch's all-or-nothingNO_DRAFTabort for at-rest plural rows can be walked around.Why it is a defect
active.Suggested disposition
Resolve the draft under the same key the caller listed it by: thread the row's
package_idfromlistDraftsthroughpromoteDraftForPublishintopromoteDraft, exactly asPromotedDraft.draftOrgIdalready threads the draft's own org scope for the analogous org-partition bug (#3115).whereForalready accepts apackageIdargument (string→ eq,null→ IS NULL), so this is threading a value, not a new query shape. The same question should be asked of the other "match any package" resolving reads that comment names (delete / restore).Related: #6215 (the same package-binding blind spot one verb over, in
restoreVersion's parent lookup).Backlink: #8858 (the measurement that surfaced this).