@@ -10382,6 +10382,18 @@ export class ObjectStackProtocolImplementation implements
1038210382 * artifact is restored to its pre-commit `prevVersion`. The revert is itself
1038310383 * recorded as a NEW commit (operation='revert'), so history stays
1038410384 * append-only and the revert is itself revertible.
10385+ *
10386+ * [#6621] BOTH limbs refresh the SchemaRegistry, so a revert that answers
10387+ * `success: true` is one the running process has already acted on. The
10388+ * restore limb writes the restored body through ({@link
10389+ * applyRegistryWriteThrough}, the #4521 rule the sibling
10390+ * {@link rollbackMetaItem} has always carried); the soft-remove limb runs
10391+ * the same three-tier heal the sibling {@link deleteMetaItem} runs after
10392+ * its own `repo.delete` ({@link restoreArtifactRegistryView}). Before
10393+ * this, a batch revert persisted its change and left the runtime
10394+ * dispatching the reverted-away body until restart —
10395+ * {@link rollbackToPackageCommit} inherited it, so a whole-package
10396+ * rollback could report success and change nothing the process could see.
1038510397 */
1038610398 async revertCommit(request: {
1038710399 commitId: string;
@@ -10451,9 +10463,8 @@ export class ObjectStackProtocolImplementation implements
1045110463 // Sibling limb: #6563 (PR #6642) did the same for the
1045210464 // restore branch below, where the intent was UNSTATED and
1045310465 // fell through to `restoreVersion`'s `?? 'override-artifact'`
10454- // default. Still not addressed here, filed with its own
10455- // measurement: neither limb refreshes the SchemaRegistry the
10456- // way `rollbackMetaItem` does (#6621).
10466+ // default. The registry half of both limbs is #6621, fixed
10467+ // here and below.
1045710468 const intent: 'override-artifact' | 'runtime-only' =
1045810469 this.isArtifactBacked(it.type, it.name) ? 'override-artifact' : 'runtime-only';
1045910470 if (current) {
@@ -10465,6 +10476,50 @@ export class ObjectStackProtocolImplementation implements
1046510476 state: 'active',
1046610477 });
1046710478 }
10479+ // [#6621] The registry must stop serving what the revert
10480+ // just removed — the #4521 rule on the DELETE side of it.
10481+ //
10482+ // Measured on `origin/main` before this line existed: a
10483+ // first-build undo of a created `object` answered
10484+ // `success: true`, left `sys_metadata` with zero rows for
10485+ // the name, and `SchemaRegistry` kept serving the body —
10486+ // the same split the restore limb below showed, one limb
10487+ // over. Same for an overlay `view` on a control-plane
10488+ // kernel, where the plain-key entry `saveMetaItem`'s
10489+ // write-through had put there simply stayed.
10490+ //
10491+ // WHICH heal, and why not a bare unregister: this is the
10492+ // #6687 three-tier walk the sibling delete caller
10493+ // {@link deleteMetaItem} runs after its own `repo.delete`,
10494+ // and the tiers are the point. A soft-removed overlay that
10495+ // shadows a packaged artifact must fall BACK to the
10496+ // artifact (tier 1, ADR-0005 reset), not vanish; only when
10497+ // no layer serves the name at all is the plain-key entry
10498+ // retired (tier 3, #5079). A flat `removeOverlayEntry`
10499+ // here would delete names a code package still ships. Both
10500+ // delete/revert callers now run the same walk, exactly as
10501+ // both now derive the same per-item intent.
10502+ //
10503+ // Run for the no-row case too, deliberately: that is the
10504+ // self-heal branch `deleteMetaItem` documents — a stale
10505+ // shadow can outlive the row it came from, and this limb's
10506+ // contract is "this artifact is not here after the revert",
10507+ // not "a row was deleted".
10508+ //
10509+ // [#6602] ORG GATE, and it is asymmetric ON PURPOSE. Only
10510+ // an env-wide revert may mutate the process-wide registry:
10511+ // an org-scoped row never entered it (ADR-0005, the rule
10512+ // {@link hydrateOverlayIntoRegistry} owns), so healing on
10513+ // its behalf would un-shadow or retire an entry that
10514+ // belongs to the env-wide row every other org reads. The
10515+ // write-through's object branch is deliberately NOT
10516+ // org-gated, and that carve-out does not transfer here: it
10517+ // is argued from `assertObjectRegistered` failing CLOSED,
10518+ // which licenses registering broadly and never retiring
10519+ // broadly. Register wide, retire narrow.
10520+ if (orgId === null) {
10521+ await this.restoreArtifactRegistryView(it.type, it.name);
10522+ }
1046810523 reverted.push({ type: it.type, name: it.name, action: 'removed' });
1046910524 } else if (it.prevVersion !== null && it.prevVersion !== undefined) {
1047010525 // Edited an existing artifact → restore the pre-commit body.
@@ -10489,18 +10544,65 @@ export class ObjectStackProtocolImplementation implements
1048910544 //
1049010545 // The soft-remove limb above stated the same intent as a
1049110546 // CONSTANT and was fixed the same way (#6620), so both limbs now
10492- // derive it. One neighbour is still open, filed with its own
10493- // measurement: neither limb refreshes the SchemaRegistry the way
10494- // `rollbackMetaItem` does, so a restored body is persisted but not
10495- // yet dispatched on (#6621).
10547+ // derive it. The registry half of both is #6621, below.
1049610548 const intent: 'override-artifact' | 'runtime-only' =
1049710549 this.isArtifactBacked(it.type, it.name) ? 'override-artifact' : 'runtime-only';
10498- await repo.restoreVersion(ref, it.prevVersion, {
10550+ // [#6621 / #4636] The ownership key the write-through needs,
10551+ // read from the ROW rather than from the request — the sibling
10552+ // revert caller {@link rollbackMetaItem} reads it exactly this
10553+ // way, and for the same reason: `revertCommit` has no
10554+ // `packageId` parameter either, and inventing one would let a
10555+ // caller re-key an artifact it does not own. Left unpassed, a
10556+ // row bound to `app.<slug>` re-registers under the
10557+ // `'sys_metadata'` sentinel and `registerObject` throws
10558+ // `already owned by package "app.<slug>"` into a best-effort
10559+ // `console.warn` — a revert that reports success while the
10560+ // registry keeps the body it was supposed to revert.
10561+ //
10562+ // Read BEFORE the restore, deliberately (#4636 again): the row
10563+ // exists at this point and a read failure still fails this ITEM
10564+ // cleanly into `failed[]`. Read afterwards it would be a
10565+ // fallible query downstream of a write that already succeeded —
10566+ // the shape that ends in a `catch {}` swallowing a real outage
10567+ // (#4867). Per ITEM, because a batch mixes bindings.
10568+ const restorePackageId = await this.resolveOverlayPackageBinding(it.type, it.name, orgId);
10569+ const restored = await repo.restoreVersion(ref, it.prevVersion, {
1049910570 actor,
1050010571 source: 'protocol.revertCommit',
1050110572 message: `revert commit ${request.commitId}`,
1050210573 intent,
1050310574 });
10575+ // [#6621] #4521 — a revert is a live write like any other: the
10576+ // restored body must be the one the runtime dispatches on
10577+ // immediately, not after someone lists the type.
10578+ //
10579+ // Measured on `origin/main` before this call existed, with the
10580+ // real `SysMetadataRepository`: an `object` saved twice and then
10581+ // reverted answered `{ success: true, revertedCount: 1,
10582+ // failed: [] }`, the stored row came back to `["name","amount"]`
10583+ // — and `SchemaRegistry.getObject(...)` still carried
10584+ // `due_date`. `success: true` while CRUD dispatches on the body
10585+ // the operator just reverted away, healing only at the next
10586+ // restart. Type-agnostic: an overlay `view` on a control-plane
10587+ // kernel showed the same split (`stored 'Cases'` vs
10588+ // `registry 'Renamed'`).
10589+ //
10590+ // The registry key is the SINGULAR type — the spelling
10591+ // `saveMetaItem`'s own write-through registered under — while
10592+ // the repo-facing reads above keep `it.type`, which is the
10593+ // spelling the row is stored with. Two different keys, on
10594+ // purpose.
10595+ this.applyRegistryWriteThrough({
10596+ type: PLURAL_TO_SINGULAR[it.type] ?? it.type,
10597+ name: it.name,
10598+ item: restored.item.body,
10599+ packageId: restorePackageId,
10600+ // [#6602] The row's OWN scope, per item. An org-scoped row
10601+ // is refused by {@link hydrateOverlayIntoRegistry} and never
10602+ // reaches the registry every org in this process shares —
10603+ // inherited, not re-decided here.
10604+ organizationId: orgId,
10605+ });
1050410606 reverted.push({ type: it.type, name: it.name, action: 'restored' });
1050510607 }
1050610608 } catch (e: any) {
0 commit comments