From bb6047b9434256761df5fa929963009910a5108d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 02:51:25 +0000 Subject: [PATCH] fix(metadata-protocol): the ADR-0010 lock gate must not fail open (#5706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getEffectiveLock` is the only source of truth for the ADR-0010 §3.3 lock gate, and both callers are write-path admission — `assertLockAllowsWrite` (save/publish/rollback) and `assertLockAllowsDelete`. Its overlay read was wrapped in a bare `catch` that fell through to `lock: 'none'`. `'none'` is not a neutral placeholder there: it is the verdict "the author declared no protection", which `evaluateLockForWrite` / `evaluateLockForDelete` turn straight into "allow". A `sys_metadata` read that FAILED therefore became a write that was PERFORMED on an item whose overlay row declared it protected. Measured on origin/main, with the row carrying `_lock` and only the gate's own read rejecting: `saveMetaItem` resolved `success: true` after `update:sys_metadata` on a `no-overlay` item, and `deleteMetaItem` the same on a `no-delete` one — while the same rows read successfully produce 403 ITEM_LOCKED. The audit trail did not compensate: the allowed path writes its ordinary `outcome: 'allowed'` row. Reuses `rethrowUnlessMetadataStoreUnprovisioned` (#5705) rather than inventing a second predicate — an unprovisioned `sys_metadata` genuinely has no overlay row, so `'none'` is the truth and first boot still saves; every other error becomes 503/SERVICE_UNAVAILABLE with the driver error as `cause`. Wire-visible, and deliberate: refusing one uncertain write beats performing one that had to be refused. Unaffected and pinned by regression tests: artifact-level locks (answered from the in-memory registry before the overlay read), a genuine miss on a healthy store, and control-plane kernels. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx --- .changeset/lock-gate-fail-closed.md | 42 ++ .../protocol.lock-gate-fail-closed.test.ts | 402 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 53 ++- 3 files changed, 495 insertions(+), 2 deletions(-) create mode 100644 .changeset/lock-gate-fail-closed.md create mode 100644 packages/metadata-protocol/src/protocol.lock-gate-fail-closed.test.ts diff --git a/.changeset/lock-gate-fail-closed.md b/.changeset/lock-gate-fail-closed.md new file mode 100644 index 0000000000..6c6f5a8fec --- /dev/null +++ b/.changeset/lock-gate-fail-closed.md @@ -0,0 +1,42 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +fix(metadata-protocol): the ADR-0010 lock gate refuses an uncertain write instead of allowing it (#5706) + +`getEffectiveLock` is the single source of truth for the ADR-0010 §3.3 lock +gate, and both of its callers are write-path admission — `assertLockAllowsWrite` +(save / publish / rollback) and `assertLockAllowsDelete`. Its overlay read was +wrapped in a bare `catch` that fell through to `lock: 'none'`. + +`'none'` is not a neutral placeholder there. It is the verdict "the author +declared no protection", and `evaluateLockForWrite` / `evaluateLockForDelete` +turn it straight into "allow". So a `sys_metadata` read that **failed** became a +write that was **performed**, on an item whose overlay row declared it +protected. Measured before the fix, with the overlay row carrying `_lock` and +only the gate's own read rejecting: `saveMetaItem` returned `success: true` +after updating a `_lock: 'no-overlay'` item, and `deleteMetaItem` returned +`success: true` after deleting a `_lock: 'no-delete'` one — while the very same +rows, read successfully, produce `403 ITEM_LOCKED`. The audit trail did not +record the miscarriage either: the allowed path writes its ordinary +`outcome: 'allowed'` row, so nothing afterwards showed the write should have +been denied. + +**Wire-visible change.** When the lock state cannot be read, `save`, `publish`, +`rollback` and `delete` now fail with `503` / `SERVICE_UNAVAILABLE` (the driver +error attached as `cause`) instead of proceeding as if the item were unlocked. +Refusing one uncertain write is the intended trade against performing one that +had to be refused. Callers that retry on 503 need no change; callers that +treated a successful save as proof the item was unlocked never had that +guarantee. + +The discrimination reuses `rethrowUnlessMetadataStoreUnprovisioned`, introduced +in #5705 for this file's overlay reads, rather than inventing a second +predicate: an unprovisioned `sys_metadata` genuinely has no overlay row, so +`'none'` is the truth and first boot still saves normally; every other error is +an outage. + +Unaffected, and covered by regression tests: artifact-level locks (answered from +the in-memory registry before the overlay read is reached), a genuine miss on a +healthy store (still allowed), and control-plane kernels (`environmentId` +undefined), which never enter either gate. diff --git a/packages/metadata-protocol/src/protocol.lock-gate-fail-closed.test.ts b/packages/metadata-protocol/src/protocol.lock-gate-fail-closed.test.ts new file mode 100644 index 0000000000..9c874ab40a --- /dev/null +++ b/packages/metadata-protocol/src/protocol.lock-gate-fail-closed.test.ts @@ -0,0 +1,402 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#5706] The ADR-0010 §3.3 lock gate must not fail OPEN. +// +// --------------------------------------------------------------------------- +// The defect +// --------------------------------------------------------------------------- +// `getEffectiveLock` is the single source of truth for the lock gate, and both +// of its callers are write-path admission: +// +// assertLockAllowsWrite → save / publish / rollback +// assertLockAllowsDelete → delete +// +// Its overlay read was wrapped in a bare `catch { /* DB unavailable */ }` that +// fell through to `lock: 'none'`. `'none'` is not a neutral placeholder there — +// it is the verdict "the author declared no protection", and +// `evaluateLockForWrite` / `evaluateLockForDelete` turn it straight into +// "allow". A `sys_metadata` read that FAILED therefore became a write that was +// PERFORMED on an item whose overlay row declared it protected. +// +// Measured on `origin/main` before the fix, with an overlay row carrying +// `_lock` and ONLY the gate's own read rejecting (`connect ECONNREFUSED`): +// +// saveMetaItem(_lock='no-overlay') -> RESOLVED { success: true } (update:sys_metadata ran) +// deleteMetaItem(_lock='no-delete') -> RESOLVED { success: true } (delete:sys_metadata ran) +// +// while the very same rows, read successfully, produce 403 ITEM_LOCKED. The +// audit trail does not save it either: the allowed path writes its ordinary +// `outcome: 'allowed'` row, so nothing afterwards records that the write should +// have been denied. (This test asserts that fact about `outcome` directly, in +// the "the audit trail did not compensate" case — it is the reason the defect +// was invisible.) +// +// ADR-0049 is fail-closed; ADR-0110 D3 is the rule that a miss and an outage +// are different facts. Reading one as the other disarmed a protection gate. +// +// --------------------------------------------------------------------------- +// The window, stated honestly +// --------------------------------------------------------------------------- +// NOT "the metadata store is down" — a fully dead store fails the write too. +// The window is "the READ failed and the write still succeeded": a transient +// error, one timed-out query, a read-replica fault, partial pool exhaustion. +// That is exactly what `engineWithTransientLockReadFault` models: the FIRST +// `sys_metadata` read (the gate's) rejects, every read and write after it +// works. Narrow — and the shape is wrong at any width. +// +// --------------------------------------------------------------------------- +// Reverse verification, direction predicted BEFORE running +// --------------------------------------------------------------------------- +// Ordinary red. Restoring the bare +// `} catch { /* DB unavailable — fall through to 'none'. */ }` turns the +// fail-closed cases red — measured 5 red / 7 green — and every one of the five +// fails in exactly the shape the issue reported, with the resolved value +// printed in the assertion message: +// +// expected a rejection, but the call resolved with +// {"success":true,…,"message":"Saved customization overlay (env-wide, …)"} +// {"success":true,"reset":true,…,"message":"Customization overlay deleted — view/v1 …"} +// +// Predicted 4 (the four in the first describe); the fifth is the last case of +// the artifact describe, which is itself a fail-closed assertion and only lives +// there for narrative reasons. Recorded as measured rather than rounded to the +// prediction. +// +// The seven that stay green under the restored defect are the point of the +// other describes, and they are green for a *reason*, not by vacuum: +// * artifact-level locks never consult the overlay at all (the read is not +// reached), so they were never affected — the regression guard proves the +// fix did not "fix" something that was already right; +// * an unprovisioned `sys_metadata` still resolves to 'none' and still +// allows the write, which is first boot and must keep working; +// * a genuine miss (healthy store, no lock row) still allows the write — +// without this, "fail closed" could be satisfied by refusing everything. + +import { describe, it, expect, vi } from 'vitest'; +import { ErrorCode } from '@objectstack/spec/api'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +/** A registry holding only what the test explicitly puts in it. */ +function registry(items: Record = {}) { + return { + getObject: () => undefined, + getItem: (_type: string, name: string) => items[name], + listItems: () => [], + applyNavContributions: (x: unknown) => x, + isPackageDisabled: () => false, + getObjectOwner: () => undefined, + }; +} + +/** An outage: the lock row may well exist and simply was not seen. */ +const connectionRefused = () => + Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:5432'), { code: 'ECONNREFUSED' }); + +/** The real driver phrasing for "the table has not been provisioned yet". */ +const missingTable = () => + Object.assign(new Error('SQLITE_ERROR: no such table: sys_metadata'), { code: 'SQLITE_ERROR' }); + +/** + * The stored overlay row the gate is supposed to read. `checksum` is what the + * optimistic-concurrency check compares against, and it must be present: + * without it the save is refused by a 409 from a LATER layer, which would mask + * whether the lock gate allowed the write at all. + */ +function lockedOverlayRow(lock: string) { + return { + id: 'row-1', + type: 'view', + name: 'v1', + state: 'active', + organization_id: null, + package_id: null, + checksum: 'sha256:stored-head', + metadata: JSON.stringify({ name: 'v1', label: 'Governed', _lock: lock, _lockReason: 'governed by ops' }), + }; +} + +type Harness = { + engine: any; + /** Every engine call, in order — the evidence that a write did or did not run. */ + calls: string[]; + /** Rows written to `sys_metadata_audit`. */ + auditRows: any[]; +}; + +/** + * The failure the issue is about: the FIRST `sys_metadata` read — the lock + * gate's own — rejects with `error`; everything after it, reads and writes + * alike, succeeds. `failFirstRead: false` gives the identical world with a + * healthy gate read, which is the control every fail-closed case is compared + * against. + */ +function engineWithTransientLockReadFault(opts: { + lock: string; + failFirstRead: boolean; + error?: () => unknown; + registryItems?: Record; + /** Omit the overlay row entirely — a genuine "nothing is locked" miss. */ + noOverlayRow?: boolean; +}): Harness { + const calls: string[] = []; + const auditRows: any[] = []; + const row = lockedOverlayRow(opts.lock); + const raise = opts.error ?? connectionRefused; + let reads = 0; + + const engine: any = { + registry: registry(opts.registryItems ?? {}), + findOne: vi.fn(async (object: string, query: any) => { + if (object !== 'sys_metadata') { + calls.push(`findOne:${object}`); + return null; + } + reads += 1; + calls.push(`findOne:sys_metadata#${reads}`); + if (opts.failFirstRead && reads === 1) throw raise(); + if (opts.noOverlayRow) return null; + if (query?.where?.state === 'draft') return null; + return row; + }), + find: vi.fn(async (object: string) => { calls.push(`find:${object}`); return []; }), + insert: vi.fn(async (object: string, values: any) => { + calls.push(`insert:${object}`); + if (object === 'sys_metadata_audit') auditRows.push(values); + return { id: 'inserted', ...values }; + }), + update: vi.fn(async (object: string) => { calls.push(`update:${object}`); return { ...row }; }), + delete: vi.fn(async (object: string) => { calls.push(`delete:${object}`); return { deleted: 1 }; }), + count: vi.fn(async () => 0), + transaction: vi.fn(async (fn: any) => fn(engine)), + execute: vi.fn(async () => ({})), + getObjectSchema: vi.fn(async () => undefined), + }; + return { engine, calls, auditRows }; +} + +/** A protocol in TENANT scope — control-plane (`environmentId` undefined) skips both gates. */ +function protocolFor(h: Harness) { + return new ObjectStackProtocolImplementation(h.engine, undefined, 'env_1'); +} + +const save = (p: ObjectStackProtocolImplementation) => + p.saveMetaItem({ type: 'view', name: 'v1', item: { name: 'v1', label: 'Edited' } } as any); + +const remove = (p: ObjectStackProtocolImplementation) => + p.deleteMetaItem({ type: 'view', name: 'v1' } as any); + +/** Capture a rejection without letting a resolve pass silently. */ +async function rejection(run: () => Promise): Promise { + let caught: any; + let resolved: unknown; + let didResolve = false; + try { + resolved = await run(); + didResolve = true; + } catch (e) { + caught = e; + } + expect( + didResolve, + `expected a rejection, but the call resolved with ${JSON.stringify(resolved)}`, + ).toBe(false); + return caught; +} + +/** Every assertion the outage envelope owes a caller (shared with #5532). */ +function expectStoreUnavailable(caught: any) { + expect(caught?.status).toBe(503); + expect(caught?.code).toBe('SERVICE_UNAVAILABLE'); + // ADR-0112: the wire code must be in the declared vocabulary, or the + // envelope fails `ApiErrorSchema.parse` at the boundary that ships it. + expect(ErrorCode.safeParse(caught?.code).success).toBe(true); + // The driver's own error rides as `cause` for the operator-side log. + expect((caught?.cause as any)?.code).toBe('ECONNREFUSED'); +} + +/** No row was written to `sys_metadata` — the write really did not happen. */ +function expectNoOverlayWrite(h: Harness) { + expect(h.calls).not.toContain('update:sys_metadata'); + expect(h.calls).not.toContain('insert:sys_metadata'); + expect(h.calls).not.toContain('delete:sys_metadata'); +} + +describe('[#5706] an unreadable lock state refuses the write instead of allowing it', () => { + it('save is refused with 503 when the gate cannot read the overlay lock', async () => { + const h = engineWithTransientLockReadFault({ lock: 'no-overlay', failFirstRead: true }); + + const caught = await rejection(() => save(protocolFor(h))); + + expectStoreUnavailable(caught); + // The regression, verbatim: this used to resolve `{ success: true }`. + expectNoOverlayWrite(h); + // It really was the GATE's read that failed — it is the first one. + expect(h.calls[0]).toBe('findOne:sys_metadata#1'); + }); + + it('delete is refused with 503 on the same unreadable lock state', async () => { + const h = engineWithTransientLockReadFault({ lock: 'no-delete', failFirstRead: true }); + + const caught = await rejection(() => remove(protocolFor(h))); + + expectStoreUnavailable(caught); + expectNoOverlayWrite(h); + expect(h.calls[0]).toBe('findOne:sys_metadata#1'); + }); + + it('a `full` lock is protected on both gates by the same read', async () => { + const saveH = engineWithTransientLockReadFault({ lock: 'full', failFirstRead: true }); + const deleteH = engineWithTransientLockReadFault({ lock: 'full', failFirstRead: true }); + + expectStoreUnavailable(await rejection(() => save(protocolFor(saveH)))); + expectStoreUnavailable(await rejection(() => remove(protocolFor(deleteH)))); + expectNoOverlayWrite(saveH); + expectNoOverlayWrite(deleteH); + }); + + it('the audit trail did not compensate — which is why this was invisible', async () => { + // Pre-fix, the allowed path wrote its ordinary `outcome: 'allowed'` + // row, so no `outcome: 'denied'` row ever recorded that this write + // should have been refused. Post-fix the write never happens, so the + // honest audit state is NO row at all — the same semantics #5705 gave + // its 503 path, not a widened audit surface. + const h = engineWithTransientLockReadFault({ lock: 'no-overlay', failFirstRead: true }); + + await rejection(() => save(protocolFor(h))); + + expect(h.auditRows.map((r) => r.outcome)).not.toContain('allowed'); + expect(h.auditRows).toEqual([]); + }); +}); + +describe('[#5706] the gate still reaches the verdicts it could actually establish', () => { + it('a readable overlay lock is still a 403 ITEM_LOCKED, not a 503', async () => { + // The control for every case above: same row, same protocol, healthy + // read. Without this, "fail closed" could be satisfied by a gate that + // merely stopped distinguishing anything. + const h = engineWithTransientLockReadFault({ lock: 'no-overlay', failFirstRead: false }); + + const caught = await rejection(() => save(protocolFor(h))); + + expect(caught.status).toBe(403); + expect(caught.code).toBe('ITEM_LOCKED'); + expect(caught.lock).toBe('no-overlay'); + expect(caught.message).toContain('source=overlay'); + expectNoOverlayWrite(h); + // The denial IS audited — this is the row the fail-open path lost. + expect(h.auditRows.map((r) => [r.operation, r.outcome])).toEqual([['save', 'denied']]); + }); + + it('a readable delete lock is still a 403 ITEM_LOCKED', async () => { + const h = engineWithTransientLockReadFault({ lock: 'no-delete', failFirstRead: false }); + + const caught = await rejection(() => remove(protocolFor(h))); + + expect(caught.status).toBe(403); + expect(caught.code).toBe('ITEM_LOCKED'); + expect(h.auditRows.map((r) => [r.operation, r.outcome])).toEqual([['delete', 'denied']]); + }); + + it('a genuine miss — healthy store, no lock row — still allows the write', async () => { + // "Fail closed" must not become "refuse everything". Nothing is locked + // here and the gate established that fact, so the save proceeds. + const h = engineWithTransientLockReadFault({ lock: 'none', failFirstRead: false, noOverlayRow: true }); + + const res: any = await save(protocolFor(h)); + + expect(res.success).toBe(true); + expect(h.calls).toContain('insert:sys_metadata'); + }); +}); + +describe('[#5706] the benign unprovisioned store is still not an outage', () => { + it('first boot: an unprovisioned sys_metadata still resolves to "unlocked" and saves', async () => { + // The table does not exist, so there genuinely are no overlay rows and + // `'none'` IS the truth. A first boot must not 503 — this is the one + // error class `rethrowUnlessMetadataStoreUnprovisioned` lets through. + const h = engineWithTransientLockReadFault({ + lock: 'none', + failFirstRead: true, + error: missingTable, + noOverlayRow: true, + }); + + const res: any = await save(protocolFor(h)); + + expect(res.success).toBe(true); + expect(h.calls[0]).toBe('findOne:sys_metadata#1'); + }); + + it('first boot: delete is likewise not turned into a 503', async () => { + const h = engineWithTransientLockReadFault({ + lock: 'none', + failFirstRead: true, + error: missingTable, + }); + + const res: any = await remove(protocolFor(h)); + + expect(res.success).toBe(true); + }); +}); + +describe('[#5706] artifact-level locks are unaffected — they never reach the overlay read', () => { + // The issue records this as a mitigation and it must stay true: a packaged + // `_lock` is answered from the in-memory registry BEFORE the overlay read, + // so it was never fail-open and must not now become a 503 either. This is + // the guard against "fixing" something that was already correct. + const packagedLock = (lock: string) => ({ + v1: { name: 'v1', _packageId: 'pkg-governance', _lock: lock, _lockReason: 'shipped locked' }, + }); + + it('a packaged `full` lock still refuses a save with 403, even mid-outage', async () => { + const h = engineWithTransientLockReadFault({ + lock: 'none', + failFirstRead: true, + registryItems: packagedLock('full'), + }); + + const caught = await rejection(() => save(protocolFor(h))); + + expect(caught.status).toBe(403); + expect(caught.code).toBe('ITEM_LOCKED'); + expect(caught.message).toContain('source=artifact'); + // The overlay read was never even attempted — artifact wins first. + expect(h.calls).not.toContain('findOne:sys_metadata#1'); + }); + + it('a packaged `no-delete` lock still refuses a delete with 403, even mid-outage', async () => { + const h = engineWithTransientLockReadFault({ + lock: 'none', + failFirstRead: true, + registryItems: packagedLock('no-delete'), + }); + + const caught = await rejection(() => remove(protocolFor(h))); + + expect(caught.status).toBe(403); + expect(caught.code).toBe('ITEM_LOCKED'); + expect(caught.message).toContain('source=artifact'); + expect(h.calls).not.toContain('findOne:sys_metadata#1'); + }); + + it('an artifact-backed item with NO packaged lock still consults the overlay — and fails closed', async () => { + // Measured, not assumed: the artifact branch short-circuits on ANY + // non-'none' packaged lock, whichever operation is being judged — it + // does not ask whether that lock blocks THIS operation. So the two + // cases above pass because the read is skipped, and this case is what + // proves the skip is conditional rather than universal: an + // artifact-backed item whose packaged `_lock` is 'none' does reach the + // overlay read, where the fix now applies. + const h = engineWithTransientLockReadFault({ + lock: 'none', + failFirstRead: true, + registryItems: { v1: { name: 'v1', _packageId: 'pkg-governance' } }, + }); + + const caught = await rejection(() => save(protocolFor(h))); + + expectStoreUnavailable(caught); + expect(h.calls).toContain('findOne:sys_metadata#1'); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 40aa03d738..01c5e86f82 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -6889,6 +6889,18 @@ export class ObjectStackProtocolImplementation implements * case. Safe to call when `environmentId` is undefined (control- * plane bootstrap) — the lock check is only meaningful in tenant * scope and the caller is expected to also gate on `environmentId`. + * + * `'none'` is a VERDICT, not a default: both callers turn it into + * "allow". So it is returned only when the absence of a lock was + * actually established. When the overlay row cannot be read this + * method THROWS (#5706) rather than answering "unlocked" — see the + * `catch` below and {@link rethrowUnlessMetadataStoreUnprovisioned}. + * + * @throws {@link metadataStoreUnavailableError} — 503 / + * `SERVICE_UNAVAILABLE`, when the lock state could not be + * determined. The one non-throwing failure is an + * unprovisioned `sys_metadata`, where "no overlay row" is + * the truth rather than an unknown. */ private async getEffectiveLock( type: string, @@ -6925,8 +6937,45 @@ export class ObjectStackProtocolImplementation implements return { lock: p.lock, lockReason: p.lockReason, lockSource: 'overlay' }; } } - } catch { - // DB unavailable — fall through to 'none'. + } catch (error) { + // #5706 — A LOCK GATE MUST NOT FAIL OPEN. This `catch` used to + // swallow every read failure and fall through to `'none'`, and + // `'none'` is not a neutral value here: it is the verdict "the + // author declared no protection", which `evaluateLockForWrite` / + // `evaluateLockForDelete` turn straight into "allow". So an + // unreadable `sys_metadata` silently converted a write that had to + // be refused into a write that was performed — measured on + // `origin/main`: with the overlay row declaring `_lock: + // 'no-overlay'` and only the read above failing, `saveMetaItem` + // returned `success: true` after an `update` on `sys_metadata` + // (and `deleteMetaItem` the same, on `_lock: 'no-delete'`). + // + // Note what that also costs the audit trail: the allowed path + // writes its ordinary `outcome: 'allowed'` row, so nothing + // afterwards records that this write should have been denied. + // + // The window is not "the metadata store is down" — a fully dead + // store fails the write too. It is "the READ failed and the write + // still succeeded": a transient error, a single timed-out query, a + // read-replica fault, partial pool exhaustion. Narrow, but the + // shape is wrong at any width, and it is the inverse of ADR-0049's + // fail-closed direction. + // + // The discrimination is the same one #5532 / PR #5705 installed for + // the overlay READS in this file, reused verbatim rather than + // reinvented: an unprovisioned `sys_metadata` genuinely has no + // overlay row, so `'none'` IS the truth and first boot must not + // explode; every other error is an outage and becomes a 503, whose + // `cause` carries the driver error. ADR-0010 §3.3 is the decision + // this defends; ADR-0110 D3 is the rule it was breaking — a miss + // and an outage are different facts, and here reading one as the + // other disarmed a protection gate. + // + // Consequence, deliberate and wire-visible: `save` / `publish` / + // `rollback` / `delete` now fail with 503 when the lock state + // cannot be read, instead of proceeding as if unlocked. Refusing + // one uncertain write beats performing one that had to be refused. + this.rethrowUnlessMetadataStoreUnprovisioned(error); } return { lock: 'none', lockReason: undefined, lockSource: undefined }; }