Skip to content

Commit 14df37f

Browse files
fix(metadata-protocol)!: a commit revert or package rollback that would restore a row bound to the read-only base is refused per item, naming the .custom pair (#29632)
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 <!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9MzJJFGjBCFjRXi73LUWGL9)_ Fixes #29390 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none needed — `skills/objectstack-platform/SKILL.md` places every customization of an installed base in its paired `.custom`, declaring `overrides`, and says nothing about reverting a commit recorded against the base itself; nothing it says becomes false. ## 维护者速读 - 改了什么:按提交撤销(`POST /packages/:id/commits/:commitId/revert`)和整包回滚(`POST /packages/:id/rollback`)时,若要把旧版本恢复进"绑定只读基础包本身"的那一行(五种可覆盖类型),现在逐项拒绝:该项进 `failed[]`,`code: NOT_OVERRIDABLE`,提示与逐项回滚完全同一句(改写进 `.custom` 配对包并声明 `overrides`),不写任何东西;同一提交的其它项照常撤销。 - 为什么改:#28905 已关掉对这一行的保存、发布和逐项回滚,但按提交撤销绕过了它。实测同一行逐项回滚答 403,按提交撤销却答 200 并改写它;整包回滚每次都把 #28905 之前的提交重放一遍。 - 风险与回滚:只收窄恢复路径,不动存量行;撤销"新建该行"的提交(即删除这行)保持开放,删除命名基础包也照旧开放。回滚即撤销本 PR。另知:配对包提交若与旧基础行并存,因提交计划按任意包读行(席位另立卡),其撤销现在会被拒而不是静默"成功"。 - 先看:`packages/metadata-protocol/src/protocol.ts` 中 `revertCommit` 恢复分支新增的判定(`restoreVersion` 之前)、`protocol.named-base-overlay-write.test.ts` 末尾新增的 describe、`package-commit-revert-path-package.dogfood.test.ts` 头注释与最后一个用例。 - 两分钟验证:`cd packages/metadata-protocol && npx vitest run src/protocol.named-base-overlay-write.test.ts`,应见 22 个用例全部通过(其中 5 个为本卡新增)。 - 需要你做的:无,席位落地。 ## What changes `@objectstack/metadata-protocol` — `revertCommit`'s restore limb (and `rollbackToPackageCommit`, which reverts through it) now asks the #28905 close before `repo.restoreVersion`: `namedBaseOverlayWriteRefusal`, on the binding the restore writes (`resolveOverlayPackageBinding`'s answer, the key `rollbackMetaItem` already uses), with the `rollback` arm of the same sentence (`closedOverlayWriteClauses`, no second spelling). - **Closed, per item:** a commit item whose restore would land in a row bound to the read-only base itself, on one of the five overlay types that base ships, where the pair fix can be taken (`namedBaseCloseSubject`, unchanged). The item lands in `failed[]` with `code: NOT_OVERRIDABLE` and the sentence; nothing is written; the revert answers `200` with `success: false`; the commit's other items still revert and the revert commit is still recorded. `rollbackToPackageCommit` reports each refused item (`Item 'TYPE/NAME' was not reverted: …`) and continues. - **Kept open:** the soft-remove limb (the revert of an item the commit created removes the row bound to the base, which writes nothing, as a delete naming the base does); every restore into a row of the pair, a writable package or no package; every restore the pair could not take. - Seat ruling (record 6579511992): option A, key A1, per-item channel. - Where the lines live: all of them stay in `protocol.ts`, in `revertCommit`'s class body (the #25467 E2 section 1 split moved `readItemFromMetadataService` through `getUiView`, not `revertCommit`). They cannot move into `protocol-packaged-base-refusal.ts` without a second spelling: the restore limb's only calls before `repo.restoreVersion` are `resolveOverlayPackageBinding` (also read by the cluster write-through replay and by `rollbackMetaItem`, which asks the package-less close first) and `SysMetadataRepository.restoreVersion` (the repository layer, no registry, shared with `rollbackMetaItem`). The sentence an item carries, for `view/showcase_task.default` on the showcase: > 'view/showcase_task.default' ships in 'com.example.showcase', and a rollback of its row bound to the read-only base is closed (ADR-0146 D1, D11): save the version's body into 'com.example.showcase.custom' (?package=com.example.showcase.custom), declaring overrides: { package: 'com.example.showcase', basedOn }. The row is served until the pair declares the override, or a delete naming the base removes it. ## Measured before the change (origin/main 7f277a8, booted showcase, REST) Commits recorded before the close were produced through the real save and `publish-drafts` doors with the close shadowed on the running protocol instance only, then the close restored (a save naming the base answered 403 again). | Door, on the row bound to `com.example.showcase` | Before | After | |---|---|---| | per-item rollback `POST /meta/view/X/rollback` | 403 `NOT_OVERRIDABLE` | unchanged | | revert of the commit that edited the row | 200 `success: true`, `restored`; row rewritten (L3 to L2), served at both addresses | 200 `success: false`, `failed[0].code` `NOT_OVERRIDABLE`; row and served document unchanged | | package rollback to the first commit | 200 `success: true`; every pre-close apply commit replayed (one reverted twice) | 200 `success: false`, each refused item in `failed[]`; row unchanged | | revert of the commit that created the row | 200 `removed`; base document served | unchanged (open) | Full readings, including which commits can carry such a row: only an `apply` commit the base recorded before the close whose item edited an existing row (creation items take the soft-remove limb; `revert` commits record `prevVersion: null`; post-close publishes into the base are refused by `promoteDraftForPublish` where the fix can be taken). ## Tests - `packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts`, new describe "the commit doors: restoring a row bound to the base is closed, removing one stays open (#29390)" (5 cases, the file's engine double reused, so the engine-double ledger is untouched). Pre-close commits come from `saveMetaItem` + `publishPackageDrafts` with the close shadowed on the instance only: - the revert of the base's commit that edited its row is refused per item naming the pair, nothing written (red first); - a package rollback through those commits reports each refused item and continues, the row unchanged (red first); - CONTROL: the revert of the base's commit that created its row removes it, the base served again; - CONTROL: a writable package's commit of the same item reverts its row in place; - CONTROL: the pair's own commit (no row bound to the base) reverts its row in place. - `packages/qa/dogfood/test/package-commit-revert-path-package.dogfood.test.ts`, new last case: on the booted showcase through REST, the showcase's commits recorded before the close: the edited item's revert and the package rollback are refused naming the pair, the served document unchanged; the created item's revert still removes the row and the shipped document is served again. - **Forced edit (prose only):** the same dogfood file's header said a commit recorded before the close "keeps its revert, as the commit doors still take no read-only guard". That is now true only for the soft-remove limb, so the header says so and names the new case (seat ruling, item 1). No assertion changed. <details><summary>Ablation (drop the close from the restore limb)</summary> `scripts/ablation-replace.mjs` replaced `if (namedBase) throw namedBase;` before `repo.restoreVersion` in the restore limb (anchor x1 to x0, blob 19bf7c8 to 15c51b71), held under an EXIT trap. - Unit leg (src import): `Tests 2 failed | 20 passed (22)`: both red-first cases, `expected { success: true, …(5) } to match object { success: false, …(3) }` and `expected true to be false` (the rollback's `success`). - Dogfood leg: `pnpm --filter @objectstack/metadata-protocol build`, `ablation-dist-preflight` marker present in `dist/index.js` and `dist/index.cjs`, then `Tests 1 failed | 7 passed (8)`: the #29390 case, `expected { success: true, …(5) } to match object { success: false, …(2) }`. - Restore: blob back to 19bf7c8 == HEAD, `git diff HEAD` empty; rebuilt, `ablation-dist-preflight --absent` marker absent from all 22 built files, tree clean. </details> <details><summary>Verification on `a15f252f`</summary> All on `a15f252f` (this branch after merging `origin/main` `0ab4a09e`, which carries the #25467 E2 section 1 split and the #29451 dogfood fix), in a card-thread container; heavy runs under `scripts/pm/os-verify-lock.sh`. - `pnpm install --frozen-lockfile`, then the closure `pnpm --filter '@objectstack/dogfood^...' build` (with declarations): VERDICT command-exit 0 each. - `src/protocol.named-base-overlay-write.test.ts`: Tests 22 passed (22). - `pnpm --filter @objectstack/metadata-protocol test`: Test Files 308 passed, 4 skipped (312); Tests 22251 passed, 26 skipped (22277); VERDICT command-exit 0. - `pnpm --filter @objectstack/metadata-protocol typecheck`: VERDICT command-exit 0. - `pnpm --filter @objectstack/objectql test` (its revert pins included: #6215's package-bound revert in place, the email-locale stage-2 batch doors): Test Files 540 passed (540); Tests 10488 passed (10488); VERDICT command-exit 0. - `pnpm --filter @objectstack/dogfood typecheck`: VERDICT command-exit 0. - `test/package-commit-revert-path-package.dogfood.test.ts`: Tests 9 passed (9) (#29450's case, merged from main, included). - Full dogfood battery, `pnpm --filter @objectstack/dogfood test` (no shard, no path): Test Files 398 passed, 3 skipped (401); Tests 3281 passed, 28 skipped (3309); VERDICT command-exit 0. - All packages built (`turbo run build --filter='./packages/**'`, 78/78), then `node scripts/pm/dispatch-gates.mjs --commands` (no paths): 65 families, all 65 run, every exit 0; `--ran` reconciliation: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm lint` (`eslint . --no-inline-config`): exit 0. - Earlier heads: on `ecf913ed` and `d776e258` the same suites, gates and lint were green, and the battery had 4 reds in `flow-runas.dogfood.test.ts` (3) and `declarative-endpoint-anonymous-guest.dogfood.test.ts` (1), red too with this PR's `src` files restored to `origin/main` (control leg); #29451 on main fixed them, and they are green here. </details> ## Acceptance notes - Out of scope, filed by the seat as its own card (serial after this one): a pair commit's revert beside a row bound to the base stored before the close is a silent no-op today, because `publishPackageDrafts`' revert plan and the restore limb both read any package's active row. With this change (key A1) that revert is refused naming the pair instead of answering success over nothing. The plan's `prevVersion` is not changed here (seat ruling). - `packages/runtime/src/domains/packages.ts` (the REST revert door's comment, outside this card's file surface) says undoing a read-only package's overlay commit "stays open": still true of the door (no package-level guard) and of the soft-remove limb; an item-level refusal now applies to the restore limb. Left for the seat to word if wanted. - The restore limb does not ask the #28576 package-less close; a package-less row is reached there only through the any-package lookup above (same family as the seat's new card). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No spec change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the `维护者速读` section is the required Chinese digest). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. Card thread session `session_01ADHkmTKKCrTrtA7H6oSj28`, seat `domain:server#3`. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmTKKCrTrtA7H6oSj28)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 3482f3d commit 14df37f

5 files changed

Lines changed: 246 additions & 12 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol)!: a commit revert or package rollback that would restore a row bound to an installed read-only package onto its view, dashboard, report, translation or email template is refused per item, naming the base's `.custom` pair
6+
7+
Clause-②: no (narrowing) — the commit revert (`POST /api/v1/packages/:id/commits/:commitId/revert`) and the package rollback (`POST /api/v1/packages/:id/rollback`) no longer restore a stored version into a row bound to an installed read-only package (`<base>`) itself, on an item of the five overlay types that package ships, where the customization can be written into the base's paired `<base>.custom` instead; they rewrote that row before. The per-item rollback of the same row has refused it since `named-base-overlay-write-closed`.
8+
9+
Metadata-protocol: none
10+
11+
Skills-docs: none needed — `skills/objectstack-platform/SKILL.md` places every customization of an installed base in its paired `.custom`, declaring `overrides`, and says nothing about reverting a commit recorded against the base itself; nothing it says becomes false.
12+
13+
<!-- adr-0087: not-required (already-registered packageless-overlay-write-closed) That entry, unreleased and shipping in the same release, already prescribes this change's TO for every customization of an installed package: write it into the base's paired `<base>.custom` with `PUT /api/v1/meta/<type>/<name>?package=<base>.custom` and the `overrides` declaration (its replacement), because the customization lives there (its reason, ADR-0146 D1, D2, D11). This change closes the commit doors' restore onto a row bound to the read-only base, the third write that still landed such a row after the save, publish and per-item rollback naming the base were closed, onto the same TO. No stored row is rewritten or moved, so `objectstack migrate meta` has nothing new to do. The entry names both meta-door addresses (the write naming no package, and the save, publish or rollback naming the read-only package, a rollback of a row bound to it included) but not yet the commit doors (`POST /api/v1/packages/<base>/commits/<commitId>/revert` and `POST /api/v1/packages/<base>/rollback`); that text widening is a `packages/spec` edit this change does not make, and it rides #29414. -->
14+
15+
**BREAKING** write narrowing at the commit doors (`@objectstack/metadata-protocol`), shipped as `patch` (`Metadata-protocol: none`).
16+
17+
### Migration: FROM → TO
18+
19+
FROM, undoing a commit that the installed package `com.acme.crm` recorded before the write naming it was closed (a customization published into the package itself), where the commit edited the customization:
20+
21+
```http
22+
POST /api/v1/packages/com.acme.crm/commits/cmt_123/revert
23+
```
24+
25+
It now answers `200` with `success: false`, and the item in `failed[]` with `code: NOT_OVERRIDABLE` and the sentence the per-item rollback gives; nothing is written. TO, write the version you want back into the base's paired `.custom`, declaring what it overrides (the pair's declared override is served over the row bound to the base):
26+
27+
```http
28+
PUT /api/v1/meta/view/account_list?package=com.acme.crm.custom
29+
{ "name": "account_list", "label": "My accounts", "overrides": { "package": "com.acme.crm", "basedOn": "1.4.0" } }
30+
```
31+
32+
Or remove the row bound to the base (`DELETE /api/v1/meta/view/account_list?package=com.acme.crm`) to serve the package's own document again.
33+
34+
- **Now refused, per item.** The restore of a commit item into a row bound to the read-only base itself, of a `view`, `dashboard`, `report`, `translation` or `email_template` that the base ships, where the pair can be ensured (the base has a namespace and a SemVer version) and the override write gate admits the declared body into it. The commit's other items still revert, and the revert is still recorded. `POST /api/v1/packages/<base>/rollback` reports each refused item in its `failed[]` (`success: false`) and continues through the remaining commits.
35+
- **Still open.** The revert of an item the commit created: it removes the row bound to the base, which writes nothing and is the one way to clear such a row (as a delete naming the base is). Every revert whose restore lands in a row of the pair, a writable package or no package, and every restore the pair could not take (a base whose pair cannot be ensured, a base with no version, an `email_template` locale the base does not ship).

‎packages/metadata-protocol/src/protocol-packaged-base-refusal.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -295,11 +295,14 @@ function closedOverlayWriteError(message: string): Error {
295295

296296
/**
297297
* The writes {@link namedBaseOverlayWriteRefusal} judges: the three that land
298-
* a row bound to the base. ⛔ Not `delete`: removing a row bound to the base
299-
* writes nothing, and it is the one way to clear such a row stored before the
300-
* close — `os migrate packageless-overlays` reads package-less rows only, so
301-
* it never moves one. The removal serves the base's own document again, or
302-
* the pair's declared override where one exists (it is served over the row).
298+
* a row bound to the base. `rollback` is every restore of a stored version:
299+
* the per-item rollback and [#29390] a commit revert's restore limb (and the
300+
* package rollback through it), one sentence for both. ⛔ Not `delete`:
301+
* removing a row bound to the base writes nothing, and it is the one way to
302+
* clear such a row stored before the close — `os migrate packageless-overlays`
303+
* reads package-less rows only, so it never moves one. The removal serves the
304+
* base's own document again, or the pair's declared override where one exists
305+
* (it is served over the row).
303306
*/
304307
export type NamedBaseWriteOperation = Exclude<PackagelessWriteOperation, 'delete'>;
305308

‎packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts‎

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
* Refusals are asserted as the envelope an HTTP boundary answers with
3636
* (`resolveThrownHttpError`).
3737
*/
38-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
38+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3939
import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
4040
import { resolveThrownHttpError } from '@objectstack/types';
4141
import { ObjectStackProtocolImplementation } from './protocol.js';
@@ -206,6 +206,7 @@ beforeEach(() => {
206206
afterEach(() => {
207207
ObjectStackProtocolImplementation.resetEnvWritableCache();
208208
resetEnvWritableMetadataTypes();
209+
vi.useRealTimers();
209210
});
210211

211212
describe('a write naming the read-only base itself is closed: its customization lands in the pair (ADR-0146 D1, D11)', () => {
@@ -362,3 +363,115 @@ describe('CONTROLS: the writes the close leaves open, as on main', () => {
362363
expect(rows(VIEW.type, VIEW.name)).toEqual([]);
363364
});
364365
});
366+
367+
/**
368+
* [#29390] The commit doors. A commit the base recorded before the close (a
369+
* draft naming the base, published by `publishPackageDrafts` with the close
370+
* shadowed on this instance only, which is what a build before #28905 did)
371+
* can carry an item that EDITED the row bound to the base. Its revert restores
372+
* a stored version into that row, so it is refused per item, as the per-item
373+
* rollback of the same row is: the restore limb asks the same close on the
374+
* same key (the binding the restore writes) with the same sentence. An item
375+
* the commit CREATED is reverted by removing the row, which writes nothing and
376+
* stays open.
377+
*/
378+
describe('the commit doors: restoring a row bound to the base is closed, removing one stays open (#29390)', () => {
379+
/** Each commit a second apart, so the package rollback's "strictly newer" reads an order. */
380+
let clock = Date.parse('2026-11-02T00:00:00.000Z');
381+
const tick = () => { clock += 1000; vi.setSystemTime(clock); };
382+
383+
/** Publish `item` into `packageId` as one commit; `beforeClose` shadows the close on this instance only. */
384+
async function publishCommit(protocol: any, packageId: string, item: Record<string, unknown>, beforeClose = false): Promise<string> {
385+
tick();
386+
if (beforeClose) protocol.namedBaseOverlayWriteRefusal = () => null;
387+
try {
388+
const saved = await protocol.saveMetaItem({ type: VIEW.type, name: VIEW.name, item, packageId, mode: 'draft' });
389+
expect(saved).toMatchObject({ success: true });
390+
const published = await protocol.publishPackageDrafts({ packageId });
391+
expect(published, JSON.stringify(published)).toMatchObject({ success: true });
392+
expect(typeof published.commitId).toBe('string');
393+
return published.commitId;
394+
} finally {
395+
if (beforeClose) delete protocol.namedBaseOverlayWriteRefusal;
396+
}
397+
}
398+
const intoBaseBeforeClose = (protocol: any, label: string) => publishCommit(protocol, BASE, VIEW.body(label), true);
399+
const declared = (label: string) => ({ ...VIEW.body(label), overrides: { package: BASE, basedOn: BASE_VERSION } });
400+
401+
beforeEach(() => {
402+
vi.useFakeTimers({ toFake: ['Date'] });
403+
});
404+
405+
it('a revert of the base\'s commit that edited its row is refused per item naming the pair, and nothing is written', async () => {
406+
const { protocol, rows } = makeProtocol();
407+
await intoBaseBeforeClose(protocol, 'First');
408+
const edit = await intoBaseBeforeClose(protocol, 'Second');
409+
// The close is live again on this instance: the save naming the base is refused.
410+
expectClosed(await thrown(() => protocol.saveMetaItem({ type: VIEW.type, name: VIEW.name, item: VIEW.body('After'), packageId: BASE })), VIEW.type, VIEW.name, 'save');
411+
412+
tick();
413+
const result = await protocol.revertCommit({ commitId: edit, packageId: BASE, actor: 'admin' });
414+
415+
expect(result).toMatchObject({ success: false, revertedCount: 0, failedCount: 1, reverted: [] });
416+
expect(result.failed).toEqual([expect.objectContaining({ type: VIEW.type, name: VIEW.name, code: 'NOT_OVERRIDABLE' })]);
417+
expectClosedSentence(result.failed[0].error, VIEW.type, VIEW.name, 'rollback');
418+
expect(rows(VIEW.type, VIEW.name)).toEqual([{ package_id: BASE, state: 'active', label: 'Second' }]);
419+
expect((await protocol.getMetaItem({ type: VIEW.type, name: VIEW.name })).item.label).toBe('Second');
420+
});
421+
422+
it('a package rollback through those commits reports each refused item and continues; the row is unchanged', async () => {
423+
const { protocol, rows } = makeProtocol();
424+
const created = await intoBaseBeforeClose(protocol, 'First');
425+
const second = await intoBaseBeforeClose(protocol, 'Second');
426+
const third = await intoBaseBeforeClose(protocol, 'Third');
427+
428+
tick();
429+
const result = await protocol.rollbackToPackageCommit({ commitId: created, packageId: BASE, actor: 'admin' });
430+
431+
expect(result.success).toBe(false);
432+
expect(result.revertedCommits).toEqual([third, second]);
433+
expect(result.failed.map((f: { commitId: string }) => f.commitId)).toEqual([third, second]);
434+
for (const f of result.failed) {
435+
expect(f.error).toContain(`Item '${VIEW.type}/${VIEW.name}' was not reverted: `);
436+
expectClosedSentence(f.error.slice(f.error.indexOf(': ') + 2), VIEW.type, VIEW.name, 'rollback');
437+
}
438+
expect(rows(VIEW.type, VIEW.name)).toEqual([{ package_id: BASE, state: 'active', label: 'Third' }]);
439+
});
440+
441+
it('CONTROL: a revert of the base\'s commit that created its row removes it (the delete stays open); the base is served again', async () => {
442+
const { protocol, rows } = makeProtocol();
443+
const created = await intoBaseBeforeClose(protocol, 'First');
444+
445+
tick();
446+
const result = await protocol.revertCommit({ commitId: created, packageId: BASE, actor: 'admin' });
447+
448+
expect(result).toMatchObject({ success: true, revertedCount: 1, failed: [], reverted: [{ type: VIEW.type, name: VIEW.name, action: 'removed' }] });
449+
expect(rows(VIEW.type, VIEW.name)).toEqual([]);
450+
expect((await protocol.getMetaItem({ type: VIEW.type, name: VIEW.name })).item.label).toBe(PACKAGED);
451+
});
452+
453+
it('CONTROL: a writable package\'s commit of the same item reverts its row in place', async () => {
454+
const { protocol, rows } = makeProtocol();
455+
await publishCommit(protocol, WRITABLE, VIEW.body('First'));
456+
const edit = await publishCommit(protocol, WRITABLE, VIEW.body('Second'));
457+
458+
tick();
459+
const result = await protocol.revertCommit({ commitId: edit, packageId: WRITABLE, actor: 'admin' });
460+
461+
expect(result).toMatchObject({ success: true, revertedCount: 1, failed: [], reverted: [{ type: VIEW.type, name: VIEW.name, action: 'restored' }] });
462+
expect(rows(VIEW.type, VIEW.name)).toEqual([{ package_id: WRITABLE, state: 'active', label: 'First' }]);
463+
});
464+
465+
it('CONTROL: the pair\'s own commit (no row bound to the base) reverts its row in place', async () => {
466+
const { protocol, rows } = makeProtocol();
467+
await publishCommit(protocol, PAIR, declared('PairFirst'));
468+
const edit = await publishCommit(protocol, PAIR, declared('PairSecond'));
469+
470+
tick();
471+
const result = await protocol.revertCommit({ commitId: edit, packageId: PAIR, actor: 'admin' });
472+
473+
expect(result).toMatchObject({ success: true, revertedCount: 1, failed: [], reverted: [{ type: VIEW.type, name: VIEW.name, action: 'restored' }] });
474+
expect(rows(VIEW.type, VIEW.name)).toEqual([{ package_id: PAIR, state: 'active', label: 'PairFirst' }]);
475+
expect((await protocol.getMetaItem({ type: VIEW.type, name: VIEW.name })).item.label).toBe('PairFirst');
476+
});
477+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19906,6 +19906,15 @@ export class ObjectStackProtocolImplementation implements
1990619906
* performs its promise exactly, and the removal is the one action that
1990719907
* reduces the residue. The measurement and the refuse-vs-decline reasoning
1990819908
* are stated at the gate itself.
19909+
*
19910+
* [#29390, ADR-0146 D1, D11] The restore limb asks the #28905 close
19911+
* ({@link namedBaseOverlayWriteRefusal}) on the binding it writes, as
19912+
* {@link rollbackMetaItem} does: an item whose restore would land in a row
19913+
* bound to the read-only base itself — a commit the base recorded before
19914+
* the write naming it closed — goes into `failed[]` (`NOT_OVERRIDABLE`, the
19915+
* rollback sentence naming the base's `.custom` pair) with nothing written,
19916+
* and {@link rollbackToPackageCommit} reports it per item and continues.
19917+
* The soft-remove limb stays open: removing such a row writes nothing.
1990919918
*/
1991019919
async revertCommit(request: {
1991119920
commitId: string;
@@ -20259,6 +20268,20 @@ export class ObjectStackProtocolImplementation implements
2025920268
// the shape that ends in a `catch {}` swallowing a real outage
2026020269
// (#4867). Per ITEM, because a batch mixes bindings.
2026120270
const restorePackageId = await this.resolveOverlayPackageBinding(it.type, it.name, member);
20271+
// [#29390, ADR-0146 D1, D11] A restore writes the row this
20272+
// binding names, so a restore into a row bound to the
20273+
// read-only base itself (a commit recorded before #28905
20274+
// closed the write naming the base) is refused exactly as
20275+
// {@link rollbackMetaItem} refuses it, on the same key and
20276+
// with the same sentence ({@link namedBaseOverlayWriteRefusal}),
20277+
// into `failed[]` with nothing written. The commit's other
20278+
// items still revert. The soft-remove limb above stays open:
20279+
// removing a row bound to the base writes nothing.
20280+
const namedBase = this.namedBaseOverlayWriteRefusal(
20281+
{ type: PLURAL_TO_SINGULAR[it.type] ?? it.type, name: it.name, packageId: restorePackageId, ...memberOption(member) },
20282+
'rollback',
20283+
);
20284+
if (namedBase) throw namedBase;
2026220285
const restored = await repo.restoreVersion(ref, restoreToVersion, {
2026320286
actor,
2026420287
source: 'protocol.revertCommit',

0 commit comments

Comments
 (0)