Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/durability-swallow-batch-7.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
---
'@objectstack/metadata-protocol': minor
'@objectstack/service-storage': minor
---

Report the refused writes three `catch { }` sites swallowed (#12981 batch 7)

Three tier-1 DARK sites from the #12981 swallow-family worklist, across two
packages. Control flow is unchanged at every one of them — none of these
failures should abort the operation it sits inside — but none of them is silent
any more.

**`metadata-protocol` — `reassignOrphanedMetadata` (the durability one).**
ADR-0070 D5's orphan-adoption loop dropped a refused `sys_metadata` update
whole: not logged, not rethrown, not carried on the response. The return line
reports `success: reassigned.length > 0`, so an adoption in which 99 of 100
orphans were refused answered `{ success: true, reassignedCount: 1 }` — a
response identical in shape to a healthy run with one orphan to move — while
the 99 stayed orphans, with nothing retrying them and no record that they had
been tried. The loop now counts refusals and states the degradation **once**
after the loop at `console.error`, naming the count, the target package, the
driver's own sentence and the fix. `error` and not this file's usual
`console.warn`, by the AGENTS.md question this turns on: the system keeps
looking normal while something it claims to have persisted did not land. It is
the verdict `recordPackageCommit` in the same file already reaches on the same
sink, and the inverse of the one `clientFacingRowFailureText` records for its
`console.warn` (there the row reports `success: false` and the counters
reconcile; here neither holds). The response shape is untouched — no
`failedCount` was added.

**`service-storage` — two sites at the tail of `StorageServicePlugin.start()`,
both functional, both `warn`.**

- The settings-namespace binding ended in `catch { }` with a comment naming
only one of the two outcomes it caught. The settings service being **absent**
(a bare kernel, where nothing ever claimed the admin UI could swap adapters)
is now resolved on its own line and stays correctly silent; a binding that
**fails with the service present** is reported, because `start()` otherwise
completes into a healthy-looking boot whose storage settings screen is wired
to nothing — an operator's adapter or credential change is saved and never
applied.
- The `storage/test` probe cleanup swallowed its own failure in
`catch { /* ignore */ }`. The result returned beside it reports the *probe's*
failure, which is a different failure: one stray `__objectstack_probe__/…`
key accrued per failed test and the only record of its name died with the
frame. The refused cleanup now names the key it left behind.

Both `service-storage` sites are `warn` on the merits, not by default: neither
is a durability degradation. Storage keeps serving from the adapter the
plugin's own options built, and the leaked probe object is inert content no
record references — AGENTS.md is explicit that escalating these is what makes
`error` unreadable. No sink type is changed at any of the three sites: the two
`service-storage` reports go to `PluginContext.logger`, whose `error` is
already non-optional, and `metadata-protocol` reports on `console`.

Each repaired seam is pinned by a test that fails if it goes quiet again, plus
absence-asserting controls — declared as controls — so a seam that reports
unconditionally cannot pass.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#12981 batch 7] A refused orphan rebind in `reassignOrphanedMetadata` must
* not be silent.
*
* ADR-0070 D5's adoption loop walked every package-less `sys_metadata` row and
* rebound it to a target base. Its `catch` was bare:
*
* } catch {
* /* skip a row that fails to update; report only what moved *\/
* }
*
* and the return line below it reports `success: reassigned.length > 0`. Put
* together, an adoption in which 99 of 100 orphans were REFUSED answered
* `{ success: true, reassignedCount: 1 }` — a response byte-identical in shape
* to a healthy run that had exactly one orphan to move. The 99 stayed orphans,
* nothing retried them, and no line anywhere recorded that they had been tried.
* That is the AGENTS.md durability shape exactly: the system keeps looking
* normal while something it claims to have persisted did not land.
*
* ## What this file pins, and what it deliberately does NOT
*
* ONLY the silence changes. The loop must still skip the refused row and adopt
* the rest — aborting the adoption over one unwritable row would strand the
* rows that CAN move — and the response shape is untouched, because adding a
* `failedCount` is a contract change this card does not carry. Both halves are
* asserted below rather than assumed.
*
* ## The level, stated so it can be argued with
*
* `console.error`, not `console.warn`, and not by default: `console.warn` is
* this file's overwhelming idiom (51 sites) and `clientFacingRowFailureText`
* records the discriminator in prose — it chose `warn` "deliberately — nothing
* claimed to be persisted was silently dropped (the row reports
* `success: false` and the counters reconcile)". Here NEITHER holds. The
* matching precedent is in this same file: `recordPackageCommit` already
* answers `console.error` for a refused `sys_metadata_commit` write under a
* publish that reports success.
*
* ## ONE line, not one per row
*
* AGENTS.md → "Degradation log levels" requires the report be stated once, at
* the first occurrence, not once per failed write — and a `sys_metadata` write
* that is refused is refused for every row, so the per-row spelling would print
* one line per orphan in the environment. The count is pinned, not just the
* presence.
*
* ⚠️ Two cases below are CONTROLS, not pins: they assert an ABSENCE against a
* seam that logged nothing at all before this repair, so they stay green in
* both directions by construction and are not evidence in an ablation.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
// [#5619] The producer's OWN write-verb dispatch decision, so this double
// cannot accept an `update` shape ObjectQL refuses. From
// `@objectstack/metadata-core` and NOT `@objectstack/objectql` — objectql
// depends on THIS package, so that import would close a cycle turbo rejects.
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { ObjectStackProtocolImplementation } from './protocol.js';

interface MetaRow {
id: string;
type: string;
name: string;
organization_id: string | null;
package_id: string | null;
}

/**
* Engine double over `sys_metadata` with a per-id refusal injector on `update`.
*
* The injection is keyed by ROW ID rather than being a global switch, because
* the defect's dangerous case is the PARTIAL one: an adoption where some rows
* move and some do not is the run that answers `success: true` while leaving
* orphans behind. A double that could only fail everything could not express
* it.
*/
function makeEngine(rows: MetaRow[]) {
const store = new Map(rows.map((r) => [r.id, { ...r }]));
const refuse = new Set<string>();
let updateAttempts = 0;

const engine = {
async find(table: string, opts?: { where?: Record<string, unknown>; limit?: number }) {
if (table !== 'sys_metadata') return [];
// This double implements NEITHER a `where` combinator NOR a bound,
// and REFUSES both rather than answering them silently. Every case
// in this file adopts env-wide orphans, so the producer passes
// `{ where: {} }` and no `limit`; the org-scoped `$or` branch and
// paging belong to tests that do not exist yet. A double looser
// than the engine it stands in for converts a green suite into no
// suite at all (#4434) — and the reason to refuse rather than
// approximate is that the approximation is invisible on the day the
// producer starts using the shape.
const where = opts?.where ?? {};
if (Object.keys(where).length > 0) {
throw new Error(`fake engine: unsupported where ${JSON.stringify(where)}`);
}
if (opts?.limit !== undefined) {
throw new Error('fake engine: unsupported `limit` — this double holds no bound');
}
return [...store.values()];
},
async update(_table: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
updateAttempts += 1;
const id = String(opts.where.id);
if (refuse.has(id)) throw new Error(`write refused for ${id}: permission denied on sys_metadata`);
const row = store.get(id);
if (!row) return { id: null };
Object.assign(row, data);
return { id };
},
};

return {
engine,
store,
refuseIds: (...ids: string[]) => ids.forEach((i) => refuse.add(i)),
updateAttempts: () => updateAttempts,
};
}

const orphan = (id: string): MetaRow => ({
id,
type: 'object',
name: `obj_${id}`,
organization_id: null,
package_id: null,
});

/** The one sentence fragment an operator greps for. */
const HEADLINE = 'orphaned metadata row(s) were NOT rebound';

afterEach(() => {
vi.restoreAllMocks();
});

function spyConsole() {
return {
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
};
}

describe('reassignOrphanedMetadata: a refused rebind is reported (#12981)', () => {
// ⚠️ CONTROL, not a pin. Before the repair this seam logged nothing at any
// level, so "a healthy adoption says nothing" was already true — it stays
// green in BOTH directions and is not ablation evidence. It is here so the
// pins below cannot pass on a seam that reports unconditionally.
it('CONTROL: an adoption in which every row rebinds reports nothing', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b')]);
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.reassignedCount).toBe(2);
expect(res.success).toBe(true);
expect(stub.updateAttempts()).toBe(2);
expect(spy.error).not.toHaveBeenCalled();
expect(spy.warn).not.toHaveBeenCalled();
});

it('reports a PARTIAL adoption — the run that still answers success: true', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b'), orphan('c')]);
stub.refuseIds('b', 'c');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

// Proof the writes were really attempted and really threw — otherwise
// every assertion below is about an adoption that never reached the seam.
expect(stub.updateAttempts()).toBe(3);
expect(stub.store.get('b')!.package_id).toBeNull();
expect(stub.store.get('c')!.package_id).toBeNull();

// ⛔ The response is UNCHANGED: this is the shape that reads healthy.
expect(res.success).toBe(true);
expect(res.reassignedCount).toBe(1);
expect(res.reassigned).toEqual([{ type: 'object', name: 'obj_a' }]);

// …and it is no longer the only thing that happened.
expect(spy.error).toHaveBeenCalledTimes(1);
const line = String(spy.error.mock.calls[0][0]);
expect(line).toContain(HEADLINE);
expect(line).toContain('2 of 3');
expect(line).toContain('app.base');
// The consequence and the fix, which AGENTS.md requires of this level.
expect(line).toContain('STILL orphans');
expect(line).toContain('Fix: restore write access');
// The driver's own sentence, so the operator is not left guessing why.
expect(line).toContain('permission denied on sys_metadata');
});

it('reports a TOTAL refusal, where the response already says success: false', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b')]);
stub.refuseIds('a', 'b');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.success).toBe(false);
expect(res.reassignedCount).toBe(0);
expect(spy.error).toHaveBeenCalledTimes(1);
expect(String(spy.error.mock.calls[0][0])).toContain('2 of 2');
});

it('states the degradation ONCE, not once per refused row', async () => {
const spy = spyConsole();
const ids = ['a', 'b', 'c', 'd', 'e', 'f'];
const stub = makeEngine(ids.map(orphan));
stub.refuseIds(...ids);
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

// Six refused writes, ONE operator-facing line — AGENTS.md's "say it
// once, at the first degradation, not once per failed write".
expect(stub.updateAttempts()).toBe(6);
expect(spy.error).toHaveBeenCalledTimes(1);
expect(String(spy.error.mock.calls[0][0])).toContain('6 of 6');
});

// ⚠️ CONTROL, not a pin — an INVARIANCE assertion. The pre-repair code
// returns exactly this too, so it stays green in both directions. It is
// here because the repair would be wrong if it changed control flow.
it('CONTROL: a refused row does not abort the rows that can still move', async () => {
spyConsole();
const stub = makeEngine([orphan('a'), orphan('b'), orphan('c')]);
stub.refuseIds('a');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.reassignedCount).toBe(2);
expect(stub.store.get('b')!.package_id).toBe('app.base');
expect(stub.store.get('c')!.package_id).toBe('app.base');
// The response shape is untouched: no `failedCount` was added.
expect(Object.keys(res).sort()).toEqual(
['reassigned', 'reassignedCount', 'success', 'targetPackageId'].sort(),
);
});
});
53 changes: 51 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17815,6 +17815,28 @@ export class ObjectStackProtocolImplementation implements
);

const reassigned: Array<{ type: string; name: string }> = [];
// [#12981] The refusal channel this loop used to lack entirely.
//
// The catch below is the card's defining shape, and this method is the
// sharpest instance of it in the file: a refused `update` was dropped
// whole -- not logged, not rethrown, not carried on the response -- and
// the return then reported `success: reassigned.length > 0`. So an
// adoption where 99 of 100 orphans were refused answered
// `{success: true, reassignedCount: 1}`, byte-identical in shape to a
// healthy run with one orphan to move, and the 99 stayed orphans with
// nothing anywhere recording that they had been tried. Nothing retries
// them and no later boot reconstructs the attempt.
//
// A COUNTER plus one report AFTER the loop, deliberately, and not a
// `console.error` inside the catch: AGENTS.md -> "Degradation log
// levels" says an operator-facing degradation is stated ONCE, at the
// first occurrence, not once per failed write -- and a refused
// `sys_metadata` write is refused for every row, so the per-row
// spelling would print one line per orphan in the environment. This is
// the same shape #12923's shared refusal accumulator takes at the other
// repaired seams of this family.
let refusedCount = 0;
let firstRefusal = '';
for (const row of orphans) {
try {
await this.engine.update(
Expand All@@ -17823,10 +17845,37 @@ export class ObjectStackProtocolImplementation implements
{ where: { id: row.id } },
);
reassigned.push({ type: row.type, name: row.name });
} catch {
/* skip a row that fails to update; report only what moved */
} catch (e: any) {
// Control flow is UNCHANGED: a row that cannot be rebound must
// not abort the adoption of the rows that can. Only the
// silence changes.
refusedCount += 1;
if (firstRefusal === '') firstRefusal = e?.message ?? String(e);
}
}
if (refusedCount > 0) {
// `error` and not `warn`, by the one question AGENTS.md turns this
// on -- after the degradation the system still looks normal from
// the outside while something it claims to have persisted did not
// land. It is the same verdict, on the same sink, that
// `recordPackageCommit` in this file already reaches for the
// `sys_metadata_commit` write, and the inverse of the one
// `clientFacingRowFailureText` records for its `console.warn`
// (there the row reports `success: false` and the counters
// reconcile, so nothing was silently dropped; here neither holds).
console.error(
`[Protocol] reassignOrphanedMetadata: ${refusedCount} of ${orphans.length} orphaned `
+ `metadata row(s) were NOT rebound to package '${request.targetPackageId}' -- the `
+ `update was REFUSED. The call still answers reassignedCount=${reassigned.length}`
+ `${reassigned.length > 0 ? ' with success: true' : ''}, so nothing looks broken, but `
+ 'those rows are STILL orphans: they keep `package_id` null or the `sys_metadata` '
+ 'sentinel, this environment has NOT converged on the package-first model (ADR-0070 '
+ 'D5 completes when an environment has no orphans), and nothing retries them. '
+ `First refusal: ${firstRefusal}. Fix: restore write access to \`sys_metadata\` for `
+ 'the system context and run the adoption again -- it is idempotent, rows already '
+ 'bound to a real package are left untouched.',
);
}
return {
success: reassigned.length > 0,
reassignedCount: reassigned.length,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Repair three durability swallows in metadata-protocol and service-storage — batch 7 of the #12981 worklist by os-steve · Pull Request #13725 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/durability-swallow-batch-7.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
---
'@objectstack/metadata-protocol': minor
'@objectstack/service-storage': minor
---

Report the refused writes three `catch { }` sites swallowed (#12981 batch 7)

Three tier-1 DARK sites from the #12981 swallow-family worklist, across two
packages. Control flow is unchanged at every one of them — none of these
failures should abort the operation it sits inside — but none of them is silent
any more.

**`metadata-protocol` — `reassignOrphanedMetadata` (the durability one).**
ADR-0070 D5's orphan-adoption loop dropped a refused `sys_metadata` update
whole: not logged, not rethrown, not carried on the response. The return line
reports `success: reassigned.length > 0`, so an adoption in which 99 of 100
orphans were refused answered `{ success: true, reassignedCount: 1 }` — a
response identical in shape to a healthy run with one orphan to move — while
the 99 stayed orphans, with nothing retrying them and no record that they had
been tried. The loop now counts refusals and states the degradation **once**
after the loop at `console.error`, naming the count, the target package, the
driver's own sentence and the fix. `error` and not this file's usual
`console.warn`, by the AGENTS.md question this turns on: the system keeps
looking normal while something it claims to have persisted did not land. It is
the verdict `recordPackageCommit` in the same file already reaches on the same
sink, and the inverse of the one `clientFacingRowFailureText` records for its
`console.warn` (there the row reports `success: false` and the counters
reconcile; here neither holds). The response shape is untouched — no
`failedCount` was added.

**`service-storage` — two sites at the tail of `StorageServicePlugin.start()`,
both functional, both `warn`.**

- The settings-namespace binding ended in `catch { }` with a comment naming
only one of the two outcomes it caught. The settings service being **absent**
(a bare kernel, where nothing ever claimed the admin UI could swap adapters)
is now resolved on its own line and stays correctly silent; a binding that
**fails with the service present** is reported, because `start()` otherwise
completes into a healthy-looking boot whose storage settings screen is wired
to nothing — an operator's adapter or credential change is saved and never
applied.
- The `storage/test` probe cleanup swallowed its own failure in
`catch { /* ignore */ }`. The result returned beside it reports the *probe's*
failure, which is a different failure: one stray `__objectstack_probe__/…`
key accrued per failed test and the only record of its name died with the
frame. The refused cleanup now names the key it left behind.

Both `service-storage` sites are `warn` on the merits, not by default: neither
is a durability degradation. Storage keeps serving from the adapter the
plugin's own options built, and the leaked probe object is inert content no
record references — AGENTS.md is explicit that escalating these is what makes
`error` unreadable. No sink type is changed at any of the three sites: the two
`service-storage` reports go to `PluginContext.logger`, whose `error` is
already non-optional, and `metadata-protocol` reports on `console`.

Each repaired seam is pinned by a test that fails if it goes quiet again, plus
absence-asserting controls — declared as controls — so a seam that reports
unconditionally cannot pass.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#12981 batch 7] A refused orphan rebind in `reassignOrphanedMetadata` must
* not be silent.
*
* ADR-0070 D5's adoption loop walked every package-less `sys_metadata` row and
* rebound it to a target base. Its `catch` was bare:
*
* } catch {
* /* skip a row that fails to update; report only what moved *\/
* }
*
* and the return line below it reports `success: reassigned.length > 0`. Put
* together, an adoption in which 99 of 100 orphans were REFUSED answered
* `{ success: true, reassignedCount: 1 }` — a response byte-identical in shape
* to a healthy run that had exactly one orphan to move. The 99 stayed orphans,
* nothing retried them, and no line anywhere recorded that they had been tried.
* That is the AGENTS.md durability shape exactly: the system keeps looking
* normal while something it claims to have persisted did not land.
*
* ## What this file pins, and what it deliberately does NOT
*
* ONLY the silence changes. The loop must still skip the refused row and adopt
* the rest — aborting the adoption over one unwritable row would strand the
* rows that CAN move — and the response shape is untouched, because adding a
* `failedCount` is a contract change this card does not carry. Both halves are
* asserted below rather than assumed.
*
* ## The level, stated so it can be argued with
*
* `console.error`, not `console.warn`, and not by default: `console.warn` is
* this file's overwhelming idiom (51 sites) and `clientFacingRowFailureText`
* records the discriminator in prose — it chose `warn` "deliberately — nothing
* claimed to be persisted was silently dropped (the row reports
* `success: false` and the counters reconcile)". Here NEITHER holds. The
* matching precedent is in this same file: `recordPackageCommit` already
* answers `console.error` for a refused `sys_metadata_commit` write under a
* publish that reports success.
*
* ## ONE line, not one per row
*
* AGENTS.md → "Degradation log levels" requires the report be stated once, at
* the first occurrence, not once per failed write — and a `sys_metadata` write
* that is refused is refused for every row, so the per-row spelling would print
* one line per orphan in the environment. The count is pinned, not just the
* presence.
*
* ⚠️ Two cases below are CONTROLS, not pins: they assert an ABSENCE against a
* seam that logged nothing at all before this repair, so they stay green in
* both directions by construction and are not evidence in an ablation.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
// [#5619] The producer's OWN write-verb dispatch decision, so this double
// cannot accept an `update` shape ObjectQL refuses. From
// `@objectstack/metadata-core` and NOT `@objectstack/objectql` — objectql
// depends on THIS package, so that import would close a cycle turbo rejects.
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { ObjectStackProtocolImplementation } from './protocol.js';

interface MetaRow {
id: string;
type: string;
name: string;
organization_id: string | null;
package_id: string | null;
}

/**
* Engine double over `sys_metadata` with a per-id refusal injector on `update`.
*
* The injection is keyed by ROW ID rather than being a global switch, because
* the defect's dangerous case is the PARTIAL one: an adoption where some rows
* move and some do not is the run that answers `success: true` while leaving
* orphans behind. A double that could only fail everything could not express
* it.
*/
function makeEngine(rows: MetaRow[]) {
const store = new Map(rows.map((r) => [r.id, { ...r }]));
const refuse = new Set<string>();
let updateAttempts = 0;

const engine = {
async find(table: string, opts?: { where?: Record<string, unknown>; limit?: number }) {
if (table !== 'sys_metadata') return [];
// This double implements NEITHER a `where` combinator NOR a bound,
// and REFUSES both rather than answering them silently. Every case
// in this file adopts env-wide orphans, so the producer passes
// `{ where: {} }` and no `limit`; the org-scoped `$or` branch and
// paging belong to tests that do not exist yet. A double looser
// than the engine it stands in for converts a green suite into no
// suite at all (#4434) — and the reason to refuse rather than
// approximate is that the approximation is invisible on the day the
// producer starts using the shape.
const where = opts?.where ?? {};
if (Object.keys(where).length > 0) {
throw new Error(`fake engine: unsupported where ${JSON.stringify(where)}`);
}
if (opts?.limit !== undefined) {
throw new Error('fake engine: unsupported `limit` — this double holds no bound');
}
return [...store.values()];
},
async update(_table: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
updateAttempts += 1;
const id = String(opts.where.id);
if (refuse.has(id)) throw new Error(`write refused for ${id}: permission denied on sys_metadata`);
const row = store.get(id);
if (!row) return { id: null };
Object.assign(row, data);
return { id };
},
};

return {
engine,
store,
refuseIds: (...ids: string[]) => ids.forEach((i) => refuse.add(i)),
updateAttempts: () => updateAttempts,
};
}

const orphan = (id: string): MetaRow => ({
id,
type: 'object',
name: `obj_${id}`,
organization_id: null,
package_id: null,
});

/** The one sentence fragment an operator greps for. */
const HEADLINE = 'orphaned metadata row(s) were NOT rebound';

afterEach(() => {
vi.restoreAllMocks();
});

function spyConsole() {
return {
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
};
}

describe('reassignOrphanedMetadata: a refused rebind is reported (#12981)', () => {
// ⚠️ CONTROL, not a pin. Before the repair this seam logged nothing at any
// level, so "a healthy adoption says nothing" was already true — it stays
// green in BOTH directions and is not ablation evidence. It is here so the
// pins below cannot pass on a seam that reports unconditionally.
it('CONTROL: an adoption in which every row rebinds reports nothing', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b')]);
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.reassignedCount).toBe(2);
expect(res.success).toBe(true);
expect(stub.updateAttempts()).toBe(2);
expect(spy.error).not.toHaveBeenCalled();
expect(spy.warn).not.toHaveBeenCalled();
});

it('reports a PARTIAL adoption — the run that still answers success: true', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b'), orphan('c')]);
stub.refuseIds('b', 'c');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

// Proof the writes were really attempted and really threw — otherwise
// every assertion below is about an adoption that never reached the seam.
expect(stub.updateAttempts()).toBe(3);
expect(stub.store.get('b')!.package_id).toBeNull();
expect(stub.store.get('c')!.package_id).toBeNull();

// ⛔ The response is UNCHANGED: this is the shape that reads healthy.
expect(res.success).toBe(true);
expect(res.reassignedCount).toBe(1);
expect(res.reassigned).toEqual([{ type: 'object', name: 'obj_a' }]);

// …and it is no longer the only thing that happened.
expect(spy.error).toHaveBeenCalledTimes(1);
const line = String(spy.error.mock.calls[0][0]);
expect(line).toContain(HEADLINE);
expect(line).toContain('2 of 3');
expect(line).toContain('app.base');
// The consequence and the fix, which AGENTS.md requires of this level.
expect(line).toContain('STILL orphans');
expect(line).toContain('Fix: restore write access');
// The driver's own sentence, so the operator is not left guessing why.
expect(line).toContain('permission denied on sys_metadata');
});

it('reports a TOTAL refusal, where the response already says success: false', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b')]);
stub.refuseIds('a', 'b');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.success).toBe(false);
expect(res.reassignedCount).toBe(0);
expect(spy.error).toHaveBeenCalledTimes(1);
expect(String(spy.error.mock.calls[0][0])).toContain('2 of 2');
});

it('states the degradation ONCE, not once per refused row', async () => {
const spy = spyConsole();
const ids = ['a', 'b', 'c', 'd', 'e', 'f'];
const stub = makeEngine(ids.map(orphan));
stub.refuseIds(...ids);
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

// Six refused writes, ONE operator-facing line — AGENTS.md's "say it
// once, at the first degradation, not once per failed write".
expect(stub.updateAttempts()).toBe(6);
expect(spy.error).toHaveBeenCalledTimes(1);
expect(String(spy.error.mock.calls[0][0])).toContain('6 of 6');
});

// ⚠️ CONTROL, not a pin — an INVARIANCE assertion. The pre-repair code
// returns exactly this too, so it stays green in both directions. It is
// here because the repair would be wrong if it changed control flow.
it('CONTROL: a refused row does not abort the rows that can still move', async () => {
spyConsole();
const stub = makeEngine([orphan('a'), orphan('b'), orphan('c')]);
stub.refuseIds('a');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.reassignedCount).toBe(2);
expect(stub.store.get('b')!.package_id).toBe('app.base');
expect(stub.store.get('c')!.package_id).toBe('app.base');
// The response shape is untouched: no `failedCount` was added.
expect(Object.keys(res).sort()).toEqual(
['reassigned', 'reassignedCount', 'success', 'targetPackageId'].sort(),
);
});
});
53 changes: 51 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17815,6 +17815,28 @@ export class ObjectStackProtocolImplementation implements
);

const reassigned: Array<{ type: string; name: string }> = [];
// [#12981] The refusal channel this loop used to lack entirely.
//
// The catch below is the card's defining shape, and this method is the
// sharpest instance of it in the file: a refused `update` was dropped
// whole -- not logged, not rethrown, not carried on the response -- and
// the return then reported `success: reassigned.length > 0`. So an
// adoption where 99 of 100 orphans were refused answered
// `{success: true, reassignedCount: 1}`, byte-identical in shape to a
// healthy run with one orphan to move, and the 99 stayed orphans with
// nothing anywhere recording that they had been tried. Nothing retries
// them and no later boot reconstructs the attempt.
//
// A COUNTER plus one report AFTER the loop, deliberately, and not a
// `console.error` inside the catch: AGENTS.md -> "Degradation log
// levels" says an operator-facing degradation is stated ONCE, at the
// first occurrence, not once per failed write -- and a refused
// `sys_metadata` write is refused for every row, so the per-row
// spelling would print one line per orphan in the environment. This is
// the same shape #12923's shared refusal accumulator takes at the other
// repaired seams of this family.
let refusedCount = 0;
let firstRefusal = '';
for (const row of orphans) {
try {
await this.engine.update(
Expand All@@ -17823,10 +17845,37 @@ export class ObjectStackProtocolImplementation implements
{ where: { id: row.id } },
);
reassigned.push({ type: row.type, name: row.name });
} catch {
/* skip a row that fails to update; report only what moved */
} catch (e: any) {
// Control flow is UNCHANGED: a row that cannot be rebound must
// not abort the adoption of the rows that can. Only the
// silence changes.
refusedCount += 1;
if (firstRefusal === '') firstRefusal = e?.message ?? String(e);
}
}
if (refusedCount > 0) {
// `error` and not `warn`, by the one question AGENTS.md turns this
// on -- after the degradation the system still looks normal from
// the outside while something it claims to have persisted did not
// land. It is the same verdict, on the same sink, that
// `recordPackageCommit` in this file already reaches for the
// `sys_metadata_commit` write, and the inverse of the one
// `clientFacingRowFailureText` records for its `console.warn`
// (there the row reports `success: false` and the counters
// reconcile, so nothing was silently dropped; here neither holds).
console.error(
`[Protocol] reassignOrphanedMetadata: ${refusedCount} of ${orphans.length} orphaned `
+ `metadata row(s) were NOT rebound to package '${request.targetPackageId}' -- the `
+ `update was REFUSED. The call still answers reassignedCount=${reassigned.length}`
+ `${reassigned.length > 0 ? ' with success: true' : ''}, so nothing looks broken, but `
+ 'those rows are STILL orphans: they keep `package_id` null or the `sys_metadata` '
+ 'sentinel, this environment has NOT converged on the package-first model (ADR-0070 '
+ 'D5 completes when an environment has no orphans), and nothing retries them. '
+ `First refusal: ${firstRefusal}. Fix: restore write access to \`sys_metadata\` for `
+ 'the system context and run the adoption again -- it is idempotent, rows already '
+ 'bound to a real package are left untouched.',
);
}
return {
success: reassigned.length > 0,
reassignedCount: reassigned.length,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Repair three durability swallows in metadata-protocol and service-storage — batch 7 of the #12981 worklist by os-steve · Pull Request #13725 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/durability-swallow-batch-7.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
---
'@objectstack/metadata-protocol': minor
'@objectstack/service-storage': minor
---

Report the refused writes three `catch { }` sites swallowed (#12981 batch 7)

Three tier-1 DARK sites from the #12981 swallow-family worklist, across two
packages. Control flow is unchanged at every one of them — none of these
failures should abort the operation it sits inside — but none of them is silent
any more.

**`metadata-protocol` — `reassignOrphanedMetadata` (the durability one).**
ADR-0070 D5's orphan-adoption loop dropped a refused `sys_metadata` update
whole: not logged, not rethrown, not carried on the response. The return line
reports `success: reassigned.length > 0`, so an adoption in which 99 of 100
orphans were refused answered `{ success: true, reassignedCount: 1 }` — a
response identical in shape to a healthy run with one orphan to move — while
the 99 stayed orphans, with nothing retrying them and no record that they had
been tried. The loop now counts refusals and states the degradation **once**
after the loop at `console.error`, naming the count, the target package, the
driver's own sentence and the fix. `error` and not this file's usual
`console.warn`, by the AGENTS.md question this turns on: the system keeps
looking normal while something it claims to have persisted did not land. It is
the verdict `recordPackageCommit` in the same file already reaches on the same
sink, and the inverse of the one `clientFacingRowFailureText` records for its
`console.warn` (there the row reports `success: false` and the counters
reconcile; here neither holds). The response shape is untouched — no
`failedCount` was added.

**`service-storage` — two sites at the tail of `StorageServicePlugin.start()`,
both functional, both `warn`.**

- The settings-namespace binding ended in `catch { }` with a comment naming
only one of the two outcomes it caught. The settings service being **absent**
(a bare kernel, where nothing ever claimed the admin UI could swap adapters)
is now resolved on its own line and stays correctly silent; a binding that
**fails with the service present** is reported, because `start()` otherwise
completes into a healthy-looking boot whose storage settings screen is wired
to nothing — an operator's adapter or credential change is saved and never
applied.
- The `storage/test` probe cleanup swallowed its own failure in
`catch { /* ignore */ }`. The result returned beside it reports the *probe's*
failure, which is a different failure: one stray `__objectstack_probe__/…`
key accrued per failed test and the only record of its name died with the
frame. The refused cleanup now names the key it left behind.

Both `service-storage` sites are `warn` on the merits, not by default: neither
is a durability degradation. Storage keeps serving from the adapter the
plugin's own options built, and the leaked probe object is inert content no
record references — AGENTS.md is explicit that escalating these is what makes
`error` unreadable. No sink type is changed at any of the three sites: the two
`service-storage` reports go to `PluginContext.logger`, whose `error` is
already non-optional, and `metadata-protocol` reports on `console`.

Each repaired seam is pinned by a test that fails if it goes quiet again, plus
absence-asserting controls — declared as controls — so a seam that reports
unconditionally cannot pass.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#12981 batch 7] A refused orphan rebind in `reassignOrphanedMetadata` must
* not be silent.
*
* ADR-0070 D5's adoption loop walked every package-less `sys_metadata` row and
* rebound it to a target base. Its `catch` was bare:
*
* } catch {
* /* skip a row that fails to update; report only what moved *\/
* }
*
* and the return line below it reports `success: reassigned.length > 0`. Put
* together, an adoption in which 99 of 100 orphans were REFUSED answered
* `{ success: true, reassignedCount: 1 }` — a response byte-identical in shape
* to a healthy run that had exactly one orphan to move. The 99 stayed orphans,
* nothing retried them, and no line anywhere recorded that they had been tried.
* That is the AGENTS.md durability shape exactly: the system keeps looking
* normal while something it claims to have persisted did not land.
*
* ## What this file pins, and what it deliberately does NOT
*
* ONLY the silence changes. The loop must still skip the refused row and adopt
* the rest — aborting the adoption over one unwritable row would strand the
* rows that CAN move — and the response shape is untouched, because adding a
* `failedCount` is a contract change this card does not carry. Both halves are
* asserted below rather than assumed.
*
* ## The level, stated so it can be argued with
*
* `console.error`, not `console.warn`, and not by default: `console.warn` is
* this file's overwhelming idiom (51 sites) and `clientFacingRowFailureText`
* records the discriminator in prose — it chose `warn` "deliberately — nothing
* claimed to be persisted was silently dropped (the row reports
* `success: false` and the counters reconcile)". Here NEITHER holds. The
* matching precedent is in this same file: `recordPackageCommit` already
* answers `console.error` for a refused `sys_metadata_commit` write under a
* publish that reports success.
*
* ## ONE line, not one per row
*
* AGENTS.md → "Degradation log levels" requires the report be stated once, at
* the first occurrence, not once per failed write — and a `sys_metadata` write
* that is refused is refused for every row, so the per-row spelling would print
* one line per orphan in the environment. The count is pinned, not just the
* presence.
*
* ⚠️ Two cases below are CONTROLS, not pins: they assert an ABSENCE against a
* seam that logged nothing at all before this repair, so they stay green in
* both directions by construction and are not evidence in an ablation.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
// [#5619] The producer's OWN write-verb dispatch decision, so this double
// cannot accept an `update` shape ObjectQL refuses. From
// `@objectstack/metadata-core` and NOT `@objectstack/objectql` — objectql
// depends on THIS package, so that import would close a cycle turbo rejects.
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { ObjectStackProtocolImplementation } from './protocol.js';

interface MetaRow {
id: string;
type: string;
name: string;
organization_id: string | null;
package_id: string | null;
}

/**
* Engine double over `sys_metadata` with a per-id refusal injector on `update`.
*
* The injection is keyed by ROW ID rather than being a global switch, because
* the defect's dangerous case is the PARTIAL one: an adoption where some rows
* move and some do not is the run that answers `success: true` while leaving
* orphans behind. A double that could only fail everything could not express
* it.
*/
function makeEngine(rows: MetaRow[]) {
const store = new Map(rows.map((r) => [r.id, { ...r }]));
const refuse = new Set<string>();
let updateAttempts = 0;

const engine = {
async find(table: string, opts?: { where?: Record<string, unknown>; limit?: number }) {
if (table !== 'sys_metadata') return [];
// This double implements NEITHER a `where` combinator NOR a bound,
// and REFUSES both rather than answering them silently. Every case
// in this file adopts env-wide orphans, so the producer passes
// `{ where: {} }` and no `limit`; the org-scoped `$or` branch and
// paging belong to tests that do not exist yet. A double looser
// than the engine it stands in for converts a green suite into no
// suite at all (#4434) — and the reason to refuse rather than
// approximate is that the approximation is invisible on the day the
// producer starts using the shape.
const where = opts?.where ?? {};
if (Object.keys(where).length > 0) {
throw new Error(`fake engine: unsupported where ${JSON.stringify(where)}`);
}
if (opts?.limit !== undefined) {
throw new Error('fake engine: unsupported `limit` — this double holds no bound');
}
return [...store.values()];
},
async update(_table: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
updateAttempts += 1;
const id = String(opts.where.id);
if (refuse.has(id)) throw new Error(`write refused for ${id}: permission denied on sys_metadata`);
const row = store.get(id);
if (!row) return { id: null };
Object.assign(row, data);
return { id };
},
};

return {
engine,
store,
refuseIds: (...ids: string[]) => ids.forEach((i) => refuse.add(i)),
updateAttempts: () => updateAttempts,
};
}

const orphan = (id: string): MetaRow => ({
id,
type: 'object',
name: `obj_${id}`,
organization_id: null,
package_id: null,
});

/** The one sentence fragment an operator greps for. */
const HEADLINE = 'orphaned metadata row(s) were NOT rebound';

afterEach(() => {
vi.restoreAllMocks();
});

function spyConsole() {
return {
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
};
}

describe('reassignOrphanedMetadata: a refused rebind is reported (#12981)', () => {
// ⚠️ CONTROL, not a pin. Before the repair this seam logged nothing at any
// level, so "a healthy adoption says nothing" was already true — it stays
// green in BOTH directions and is not ablation evidence. It is here so the
// pins below cannot pass on a seam that reports unconditionally.
it('CONTROL: an adoption in which every row rebinds reports nothing', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b')]);
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.reassignedCount).toBe(2);
expect(res.success).toBe(true);
expect(stub.updateAttempts()).toBe(2);
expect(spy.error).not.toHaveBeenCalled();
expect(spy.warn).not.toHaveBeenCalled();
});

it('reports a PARTIAL adoption — the run that still answers success: true', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b'), orphan('c')]);
stub.refuseIds('b', 'c');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

// Proof the writes were really attempted and really threw — otherwise
// every assertion below is about an adoption that never reached the seam.
expect(stub.updateAttempts()).toBe(3);
expect(stub.store.get('b')!.package_id).toBeNull();
expect(stub.store.get('c')!.package_id).toBeNull();

// ⛔ The response is UNCHANGED: this is the shape that reads healthy.
expect(res.success).toBe(true);
expect(res.reassignedCount).toBe(1);
expect(res.reassigned).toEqual([{ type: 'object', name: 'obj_a' }]);

// …and it is no longer the only thing that happened.
expect(spy.error).toHaveBeenCalledTimes(1);
const line = String(spy.error.mock.calls[0][0]);
expect(line).toContain(HEADLINE);
expect(line).toContain('2 of 3');
expect(line).toContain('app.base');
// The consequence and the fix, which AGENTS.md requires of this level.
expect(line).toContain('STILL orphans');
expect(line).toContain('Fix: restore write access');
// The driver's own sentence, so the operator is not left guessing why.
expect(line).toContain('permission denied on sys_metadata');
});

it('reports a TOTAL refusal, where the response already says success: false', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b')]);
stub.refuseIds('a', 'b');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.success).toBe(false);
expect(res.reassignedCount).toBe(0);
expect(spy.error).toHaveBeenCalledTimes(1);
expect(String(spy.error.mock.calls[0][0])).toContain('2 of 2');
});

it('states the degradation ONCE, not once per refused row', async () => {
const spy = spyConsole();
const ids = ['a', 'b', 'c', 'd', 'e', 'f'];
const stub = makeEngine(ids.map(orphan));
stub.refuseIds(...ids);
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

// Six refused writes, ONE operator-facing line — AGENTS.md's "say it
// once, at the first degradation, not once per failed write".
expect(stub.updateAttempts()).toBe(6);
expect(spy.error).toHaveBeenCalledTimes(1);
expect(String(spy.error.mock.calls[0][0])).toContain('6 of 6');
});

// ⚠️ CONTROL, not a pin — an INVARIANCE assertion. The pre-repair code
// returns exactly this too, so it stays green in both directions. It is
// here because the repair would be wrong if it changed control flow.
it('CONTROL: a refused row does not abort the rows that can still move', async () => {
spyConsole();
const stub = makeEngine([orphan('a'), orphan('b'), orphan('c')]);
stub.refuseIds('a');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.reassignedCount).toBe(2);
expect(stub.store.get('b')!.package_id).toBe('app.base');
expect(stub.store.get('c')!.package_id).toBe('app.base');
// The response shape is untouched: no `failedCount` was added.
expect(Object.keys(res).sort()).toEqual(
['reassigned', 'reassignedCount', 'success', 'targetPackageId'].sort(),
);
});
});
53 changes: 51 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17815,6 +17815,28 @@ export class ObjectStackProtocolImplementation implements
);

const reassigned: Array<{ type: string; name: string }> = [];
// [#12981] The refusal channel this loop used to lack entirely.
//
// The catch below is the card's defining shape, and this method is the
// sharpest instance of it in the file: a refused `update` was dropped
// whole -- not logged, not rethrown, not carried on the response -- and
// the return then reported `success: reassigned.length > 0`. So an
// adoption where 99 of 100 orphans were refused answered
// `{success: true, reassignedCount: 1}`, byte-identical in shape to a
// healthy run with one orphan to move, and the 99 stayed orphans with
// nothing anywhere recording that they had been tried. Nothing retries
// them and no later boot reconstructs the attempt.
//
// A COUNTER plus one report AFTER the loop, deliberately, and not a
// `console.error` inside the catch: AGENTS.md -> "Degradation log
// levels" says an operator-facing degradation is stated ONCE, at the
// first occurrence, not once per failed write -- and a refused
// `sys_metadata` write is refused for every row, so the per-row
// spelling would print one line per orphan in the environment. This is
// the same shape #12923's shared refusal accumulator takes at the other
// repaired seams of this family.
let refusedCount = 0;
let firstRefusal = '';
for (const row of orphans) {
try {
await this.engine.update(
Expand All@@ -17823,10 +17845,37 @@ export class ObjectStackProtocolImplementation implements
{ where: { id: row.id } },
);
reassigned.push({ type: row.type, name: row.name });
} catch {
/* skip a row that fails to update; report only what moved */
} catch (e: any) {
// Control flow is UNCHANGED: a row that cannot be rebound must
// not abort the adoption of the rows that can. Only the
// silence changes.
refusedCount += 1;
if (firstRefusal === '') firstRefusal = e?.message ?? String(e);
}
}
if (refusedCount > 0) {
// `error` and not `warn`, by the one question AGENTS.md turns this
// on -- after the degradation the system still looks normal from
// the outside while something it claims to have persisted did not
// land. It is the same verdict, on the same sink, that
// `recordPackageCommit` in this file already reaches for the
// `sys_metadata_commit` write, and the inverse of the one
// `clientFacingRowFailureText` records for its `console.warn`
// (there the row reports `success: false` and the counters
// reconcile, so nothing was silently dropped; here neither holds).
console.error(
`[Protocol] reassignOrphanedMetadata: ${refusedCount} of ${orphans.length} orphaned `
+ `metadata row(s) were NOT rebound to package '${request.targetPackageId}' -- the `
+ `update was REFUSED. The call still answers reassignedCount=${reassigned.length}`
+ `${reassigned.length > 0 ? ' with success: true' : ''}, so nothing looks broken, but `
+ 'those rows are STILL orphans: they keep `package_id` null or the `sys_metadata` '
+ 'sentinel, this environment has NOT converged on the package-first model (ADR-0070 '
+ 'D5 completes when an environment has no orphans), and nothing retries them. '
+ `First refusal: ${firstRefusal}. Fix: restore write access to \`sys_metadata\` for `
+ 'the system context and run the adoption again -- it is idempotent, rows already '
+ 'bound to a real package are left untouched.',
);
}
return {
success: reassigned.length > 0,
reassignedCount: reassigned.length,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Repair three durability swallows in metadata-protocol and service-storage — batch 7 of the #12981 worklist by os-steve · Pull Request #13725 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/durability-swallow-batch-7.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
---
'@objectstack/metadata-protocol': minor
'@objectstack/service-storage': minor
---

Report the refused writes three `catch { }` sites swallowed (#12981 batch 7)

Three tier-1 DARK sites from the #12981 swallow-family worklist, across two
packages. Control flow is unchanged at every one of them — none of these
failures should abort the operation it sits inside — but none of them is silent
any more.

**`metadata-protocol` — `reassignOrphanedMetadata` (the durability one).**
ADR-0070 D5's orphan-adoption loop dropped a refused `sys_metadata` update
whole: not logged, not rethrown, not carried on the response. The return line
reports `success: reassigned.length > 0`, so an adoption in which 99 of 100
orphans were refused answered `{ success: true, reassignedCount: 1 }` — a
response identical in shape to a healthy run with one orphan to move — while
the 99 stayed orphans, with nothing retrying them and no record that they had
been tried. The loop now counts refusals and states the degradation **once**
after the loop at `console.error`, naming the count, the target package, the
driver's own sentence and the fix. `error` and not this file's usual
`console.warn`, by the AGENTS.md question this turns on: the system keeps
looking normal while something it claims to have persisted did not land. It is
the verdict `recordPackageCommit` in the same file already reaches on the same
sink, and the inverse of the one `clientFacingRowFailureText` records for its
`console.warn` (there the row reports `success: false` and the counters
reconcile; here neither holds). The response shape is untouched — no
`failedCount` was added.

**`service-storage` — two sites at the tail of `StorageServicePlugin.start()`,
both functional, both `warn`.**

- The settings-namespace binding ended in `catch { }` with a comment naming
only one of the two outcomes it caught. The settings service being **absent**
(a bare kernel, where nothing ever claimed the admin UI could swap adapters)
is now resolved on its own line and stays correctly silent; a binding that
**fails with the service present** is reported, because `start()` otherwise
completes into a healthy-looking boot whose storage settings screen is wired
to nothing — an operator's adapter or credential change is saved and never
applied.
- The `storage/test` probe cleanup swallowed its own failure in
`catch { /* ignore */ }`. The result returned beside it reports the *probe's*
failure, which is a different failure: one stray `__objectstack_probe__/…`
key accrued per failed test and the only record of its name died with the
frame. The refused cleanup now names the key it left behind.

Both `service-storage` sites are `warn` on the merits, not by default: neither
is a durability degradation. Storage keeps serving from the adapter the
plugin's own options built, and the leaked probe object is inert content no
record references — AGENTS.md is explicit that escalating these is what makes
`error` unreadable. No sink type is changed at any of the three sites: the two
`service-storage` reports go to `PluginContext.logger`, whose `error` is
already non-optional, and `metadata-protocol` reports on `console`.

Each repaired seam is pinned by a test that fails if it goes quiet again, plus
absence-asserting controls — declared as controls — so a seam that reports
unconditionally cannot pass.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#12981 batch 7] A refused orphan rebind in `reassignOrphanedMetadata` must
* not be silent.
*
* ADR-0070 D5's adoption loop walked every package-less `sys_metadata` row and
* rebound it to a target base. Its `catch` was bare:
*
* } catch {
* /* skip a row that fails to update; report only what moved *\/
* }
*
* and the return line below it reports `success: reassigned.length > 0`. Put
* together, an adoption in which 99 of 100 orphans were REFUSED answered
* `{ success: true, reassignedCount: 1 }` — a response byte-identical in shape
* to a healthy run that had exactly one orphan to move. The 99 stayed orphans,
* nothing retried them, and no line anywhere recorded that they had been tried.
* That is the AGENTS.md durability shape exactly: the system keeps looking
* normal while something it claims to have persisted did not land.
*
* ## What this file pins, and what it deliberately does NOT
*
* ONLY the silence changes. The loop must still skip the refused row and adopt
* the rest — aborting the adoption over one unwritable row would strand the
* rows that CAN move — and the response shape is untouched, because adding a
* `failedCount` is a contract change this card does not carry. Both halves are
* asserted below rather than assumed.
*
* ## The level, stated so it can be argued with
*
* `console.error`, not `console.warn`, and not by default: `console.warn` is
* this file's overwhelming idiom (51 sites) and `clientFacingRowFailureText`
* records the discriminator in prose — it chose `warn` "deliberately — nothing
* claimed to be persisted was silently dropped (the row reports
* `success: false` and the counters reconcile)". Here NEITHER holds. The
* matching precedent is in this same file: `recordPackageCommit` already
* answers `console.error` for a refused `sys_metadata_commit` write under a
* publish that reports success.
*
* ## ONE line, not one per row
*
* AGENTS.md → "Degradation log levels" requires the report be stated once, at
* the first occurrence, not once per failed write — and a `sys_metadata` write
* that is refused is refused for every row, so the per-row spelling would print
* one line per orphan in the environment. The count is pinned, not just the
* presence.
*
* ⚠️ Two cases below are CONTROLS, not pins: they assert an ABSENCE against a
* seam that logged nothing at all before this repair, so they stay green in
* both directions by construction and are not evidence in an ablation.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
// [#5619] The producer's OWN write-verb dispatch decision, so this double
// cannot accept an `update` shape ObjectQL refuses. From
// `@objectstack/metadata-core` and NOT `@objectstack/objectql` — objectql
// depends on THIS package, so that import would close a cycle turbo rejects.
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { ObjectStackProtocolImplementation } from './protocol.js';

interface MetaRow {
id: string;
type: string;
name: string;
organization_id: string | null;
package_id: string | null;
}

/**
* Engine double over `sys_metadata` with a per-id refusal injector on `update`.
*
* The injection is keyed by ROW ID rather than being a global switch, because
* the defect's dangerous case is the PARTIAL one: an adoption where some rows
* move and some do not is the run that answers `success: true` while leaving
* orphans behind. A double that could only fail everything could not express
* it.
*/
function makeEngine(rows: MetaRow[]) {
const store = new Map(rows.map((r) => [r.id, { ...r }]));
const refuse = new Set<string>();
let updateAttempts = 0;

const engine = {
async find(table: string, opts?: { where?: Record<string, unknown>; limit?: number }) {
if (table !== 'sys_metadata') return [];
// This double implements NEITHER a `where` combinator NOR a bound,
// and REFUSES both rather than answering them silently. Every case
// in this file adopts env-wide orphans, so the producer passes
// `{ where: {} }` and no `limit`; the org-scoped `$or` branch and
// paging belong to tests that do not exist yet. A double looser
// than the engine it stands in for converts a green suite into no
// suite at all (#4434) — and the reason to refuse rather than
// approximate is that the approximation is invisible on the day the
// producer starts using the shape.
const where = opts?.where ?? {};
if (Object.keys(where).length > 0) {
throw new Error(`fake engine: unsupported where ${JSON.stringify(where)}`);
}
if (opts?.limit !== undefined) {
throw new Error('fake engine: unsupported `limit` — this double holds no bound');
}
return [...store.values()];
},
async update(_table: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
updateAttempts += 1;
const id = String(opts.where.id);
if (refuse.has(id)) throw new Error(`write refused for ${id}: permission denied on sys_metadata`);
const row = store.get(id);
if (!row) return { id: null };
Object.assign(row, data);
return { id };
},
};

return {
engine,
store,
refuseIds: (...ids: string[]) => ids.forEach((i) => refuse.add(i)),
updateAttempts: () => updateAttempts,
};
}

const orphan = (id: string): MetaRow => ({
id,
type: 'object',
name: `obj_${id}`,
organization_id: null,
package_id: null,
});

/** The one sentence fragment an operator greps for. */
const HEADLINE = 'orphaned metadata row(s) were NOT rebound';

afterEach(() => {
vi.restoreAllMocks();
});

function spyConsole() {
return {
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
};
}

describe('reassignOrphanedMetadata: a refused rebind is reported (#12981)', () => {
// ⚠️ CONTROL, not a pin. Before the repair this seam logged nothing at any
// level, so "a healthy adoption says nothing" was already true — it stays
// green in BOTH directions and is not ablation evidence. It is here so the
// pins below cannot pass on a seam that reports unconditionally.
it('CONTROL: an adoption in which every row rebinds reports nothing', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b')]);
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.reassignedCount).toBe(2);
expect(res.success).toBe(true);
expect(stub.updateAttempts()).toBe(2);
expect(spy.error).not.toHaveBeenCalled();
expect(spy.warn).not.toHaveBeenCalled();
});

it('reports a PARTIAL adoption — the run that still answers success: true', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b'), orphan('c')]);
stub.refuseIds('b', 'c');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

// Proof the writes were really attempted and really threw — otherwise
// every assertion below is about an adoption that never reached the seam.
expect(stub.updateAttempts()).toBe(3);
expect(stub.store.get('b')!.package_id).toBeNull();
expect(stub.store.get('c')!.package_id).toBeNull();

// ⛔ The response is UNCHANGED: this is the shape that reads healthy.
expect(res.success).toBe(true);
expect(res.reassignedCount).toBe(1);
expect(res.reassigned).toEqual([{ type: 'object', name: 'obj_a' }]);

// …and it is no longer the only thing that happened.
expect(spy.error).toHaveBeenCalledTimes(1);
const line = String(spy.error.mock.calls[0][0]);
expect(line).toContain(HEADLINE);
expect(line).toContain('2 of 3');
expect(line).toContain('app.base');
// The consequence and the fix, which AGENTS.md requires of this level.
expect(line).toContain('STILL orphans');
expect(line).toContain('Fix: restore write access');
// The driver's own sentence, so the operator is not left guessing why.
expect(line).toContain('permission denied on sys_metadata');
});

it('reports a TOTAL refusal, where the response already says success: false', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b')]);
stub.refuseIds('a', 'b');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.success).toBe(false);
expect(res.reassignedCount).toBe(0);
expect(spy.error).toHaveBeenCalledTimes(1);
expect(String(spy.error.mock.calls[0][0])).toContain('2 of 2');
});

it('states the degradation ONCE, not once per refused row', async () => {
const spy = spyConsole();
const ids = ['a', 'b', 'c', 'd', 'e', 'f'];
const stub = makeEngine(ids.map(orphan));
stub.refuseIds(...ids);
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

// Six refused writes, ONE operator-facing line — AGENTS.md's "say it
// once, at the first degradation, not once per failed write".
expect(stub.updateAttempts()).toBe(6);
expect(spy.error).toHaveBeenCalledTimes(1);
expect(String(spy.error.mock.calls[0][0])).toContain('6 of 6');
});

// ⚠️ CONTROL, not a pin — an INVARIANCE assertion. The pre-repair code
// returns exactly this too, so it stays green in both directions. It is
// here because the repair would be wrong if it changed control flow.
it('CONTROL: a refused row does not abort the rows that can still move', async () => {
spyConsole();
const stub = makeEngine([orphan('a'), orphan('b'), orphan('c')]);
stub.refuseIds('a');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.reassignedCount).toBe(2);
expect(stub.store.get('b')!.package_id).toBe('app.base');
expect(stub.store.get('c')!.package_id).toBe('app.base');
// The response shape is untouched: no `failedCount` was added.
expect(Object.keys(res).sort()).toEqual(
['reassigned', 'reassignedCount', 'success', 'targetPackageId'].sort(),
);
});
});
53 changes: 51 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17815,6 +17815,28 @@ export class ObjectStackProtocolImplementation implements
);

const reassigned: Array<{ type: string; name: string }> = [];
// [#12981] The refusal channel this loop used to lack entirely.
//
// The catch below is the card's defining shape, and this method is the
// sharpest instance of it in the file: a refused `update` was dropped
// whole -- not logged, not rethrown, not carried on the response -- and
// the return then reported `success: reassigned.length > 0`. So an
// adoption where 99 of 100 orphans were refused answered
// `{success: true, reassignedCount: 1}`, byte-identical in shape to a
// healthy run with one orphan to move, and the 99 stayed orphans with
// nothing anywhere recording that they had been tried. Nothing retries
// them and no later boot reconstructs the attempt.
//
// A COUNTER plus one report AFTER the loop, deliberately, and not a
// `console.error` inside the catch: AGENTS.md -> "Degradation log
// levels" says an operator-facing degradation is stated ONCE, at the
// first occurrence, not once per failed write -- and a refused
// `sys_metadata` write is refused for every row, so the per-row
// spelling would print one line per orphan in the environment. This is
// the same shape #12923's shared refusal accumulator takes at the other
// repaired seams of this family.
let refusedCount = 0;
let firstRefusal = '';
for (const row of orphans) {
try {
await this.engine.update(
Expand All@@ -17823,10 +17845,37 @@ export class ObjectStackProtocolImplementation implements
{ where: { id: row.id } },
);
reassigned.push({ type: row.type, name: row.name });
} catch {
/* skip a row that fails to update; report only what moved */
} catch (e: any) {
// Control flow is UNCHANGED: a row that cannot be rebound must
// not abort the adoption of the rows that can. Only the
// silence changes.
refusedCount += 1;
if (firstRefusal === '') firstRefusal = e?.message ?? String(e);
}
}
if (refusedCount > 0) {
// `error` and not `warn`, by the one question AGENTS.md turns this
// on -- after the degradation the system still looks normal from
// the outside while something it claims to have persisted did not
// land. It is the same verdict, on the same sink, that
// `recordPackageCommit` in this file already reaches for the
// `sys_metadata_commit` write, and the inverse of the one
// `clientFacingRowFailureText` records for its `console.warn`
// (there the row reports `success: false` and the counters
// reconcile, so nothing was silently dropped; here neither holds).
console.error(
`[Protocol] reassignOrphanedMetadata: ${refusedCount} of ${orphans.length} orphaned `
+ `metadata row(s) were NOT rebound to package '${request.targetPackageId}' -- the `
+ `update was REFUSED. The call still answers reassignedCount=${reassigned.length}`
+ `${reassigned.length > 0 ? ' with success: true' : ''}, so nothing looks broken, but `
+ 'those rows are STILL orphans: they keep `package_id` null or the `sys_metadata` '
+ 'sentinel, this environment has NOT converged on the package-first model (ADR-0070 '
+ 'D5 completes when an environment has no orphans), and nothing retries them. '
+ `First refusal: ${firstRefusal}. Fix: restore write access to \`sys_metadata\` for `
+ 'the system context and run the adoption again -- it is idempotent, rows already '
+ 'bound to a real package are left untouched.',
);
}
return {
success: reassigned.length > 0,
reassignedCount: reassigned.length,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Repair three durability swallows in metadata-protocol and service-storage — batch 7 of the #12981 worklist by os-steve · Pull Request #13725 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/durability-swallow-batch-7.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
---
'@objectstack/metadata-protocol': minor
'@objectstack/service-storage': minor
---

Report the refused writes three `catch { }` sites swallowed (#12981 batch 7)

Three tier-1 DARK sites from the #12981 swallow-family worklist, across two
packages. Control flow is unchanged at every one of them — none of these
failures should abort the operation it sits inside — but none of them is silent
any more.

**`metadata-protocol` — `reassignOrphanedMetadata` (the durability one).**
ADR-0070 D5's orphan-adoption loop dropped a refused `sys_metadata` update
whole: not logged, not rethrown, not carried on the response. The return line
reports `success: reassigned.length > 0`, so an adoption in which 99 of 100
orphans were refused answered `{ success: true, reassignedCount: 1 }` — a
response identical in shape to a healthy run with one orphan to move — while
the 99 stayed orphans, with nothing retrying them and no record that they had
been tried. The loop now counts refusals and states the degradation **once**
after the loop at `console.error`, naming the count, the target package, the
driver's own sentence and the fix. `error` and not this file's usual
`console.warn`, by the AGENTS.md question this turns on: the system keeps
looking normal while something it claims to have persisted did not land. It is
the verdict `recordPackageCommit` in the same file already reaches on the same
sink, and the inverse of the one `clientFacingRowFailureText` records for its
`console.warn` (there the row reports `success: false` and the counters
reconcile; here neither holds). The response shape is untouched — no
`failedCount` was added.

**`service-storage` — two sites at the tail of `StorageServicePlugin.start()`,
both functional, both `warn`.**

- The settings-namespace binding ended in `catch { }` with a comment naming
only one of the two outcomes it caught. The settings service being **absent**
(a bare kernel, where nothing ever claimed the admin UI could swap adapters)
is now resolved on its own line and stays correctly silent; a binding that
**fails with the service present** is reported, because `start()` otherwise
completes into a healthy-looking boot whose storage settings screen is wired
to nothing — an operator's adapter or credential change is saved and never
applied.
- The `storage/test` probe cleanup swallowed its own failure in
`catch { /* ignore */ }`. The result returned beside it reports the *probe's*
failure, which is a different failure: one stray `__objectstack_probe__/…`
key accrued per failed test and the only record of its name died with the
frame. The refused cleanup now names the key it left behind.

Both `service-storage` sites are `warn` on the merits, not by default: neither
is a durability degradation. Storage keeps serving from the adapter the
plugin's own options built, and the leaked probe object is inert content no
record references — AGENTS.md is explicit that escalating these is what makes
`error` unreadable. No sink type is changed at any of the three sites: the two
`service-storage` reports go to `PluginContext.logger`, whose `error` is
already non-optional, and `metadata-protocol` reports on `console`.

Each repaired seam is pinned by a test that fails if it goes quiet again, plus
absence-asserting controls — declared as controls — so a seam that reports
unconditionally cannot pass.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#12981 batch 7] A refused orphan rebind in `reassignOrphanedMetadata` must
* not be silent.
*
* ADR-0070 D5's adoption loop walked every package-less `sys_metadata` row and
* rebound it to a target base. Its `catch` was bare:
*
* } catch {
* /* skip a row that fails to update; report only what moved *\/
* }
*
* and the return line below it reports `success: reassigned.length > 0`. Put
* together, an adoption in which 99 of 100 orphans were REFUSED answered
* `{ success: true, reassignedCount: 1 }` — a response byte-identical in shape
* to a healthy run that had exactly one orphan to move. The 99 stayed orphans,
* nothing retried them, and no line anywhere recorded that they had been tried.
* That is the AGENTS.md durability shape exactly: the system keeps looking
* normal while something it claims to have persisted did not land.
*
* ## What this file pins, and what it deliberately does NOT
*
* ONLY the silence changes. The loop must still skip the refused row and adopt
* the rest — aborting the adoption over one unwritable row would strand the
* rows that CAN move — and the response shape is untouched, because adding a
* `failedCount` is a contract change this card does not carry. Both halves are
* asserted below rather than assumed.
*
* ## The level, stated so it can be argued with
*
* `console.error`, not `console.warn`, and not by default: `console.warn` is
* this file's overwhelming idiom (51 sites) and `clientFacingRowFailureText`
* records the discriminator in prose — it chose `warn` "deliberately — nothing
* claimed to be persisted was silently dropped (the row reports
* `success: false` and the counters reconcile)". Here NEITHER holds. The
* matching precedent is in this same file: `recordPackageCommit` already
* answers `console.error` for a refused `sys_metadata_commit` write under a
* publish that reports success.
*
* ## ONE line, not one per row
*
* AGENTS.md → "Degradation log levels" requires the report be stated once, at
* the first occurrence, not once per failed write — and a `sys_metadata` write
* that is refused is refused for every row, so the per-row spelling would print
* one line per orphan in the environment. The count is pinned, not just the
* presence.
*
* ⚠️ Two cases below are CONTROLS, not pins: they assert an ABSENCE against a
* seam that logged nothing at all before this repair, so they stay green in
* both directions by construction and are not evidence in an ablation.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
// [#5619] The producer's OWN write-verb dispatch decision, so this double
// cannot accept an `update` shape ObjectQL refuses. From
// `@objectstack/metadata-core` and NOT `@objectstack/objectql` — objectql
// depends on THIS package, so that import would close a cycle turbo rejects.
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { ObjectStackProtocolImplementation } from './protocol.js';

interface MetaRow {
id: string;
type: string;
name: string;
organization_id: string | null;
package_id: string | null;
}

/**
* Engine double over `sys_metadata` with a per-id refusal injector on `update`.
*
* The injection is keyed by ROW ID rather than being a global switch, because
* the defect's dangerous case is the PARTIAL one: an adoption where some rows
* move and some do not is the run that answers `success: true` while leaving
* orphans behind. A double that could only fail everything could not express
* it.
*/
function makeEngine(rows: MetaRow[]) {
const store = new Map(rows.map((r) => [r.id, { ...r }]));
const refuse = new Set<string>();
let updateAttempts = 0;

const engine = {
async find(table: string, opts?: { where?: Record<string, unknown>; limit?: number }) {
if (table !== 'sys_metadata') return [];
// This double implements NEITHER a `where` combinator NOR a bound,
// and REFUSES both rather than answering them silently. Every case
// in this file adopts env-wide orphans, so the producer passes
// `{ where: {} }` and no `limit`; the org-scoped `$or` branch and
// paging belong to tests that do not exist yet. A double looser
// than the engine it stands in for converts a green suite into no
// suite at all (#4434) — and the reason to refuse rather than
// approximate is that the approximation is invisible on the day the
// producer starts using the shape.
const where = opts?.where ?? {};
if (Object.keys(where).length > 0) {
throw new Error(`fake engine: unsupported where ${JSON.stringify(where)}`);
}
if (opts?.limit !== undefined) {
throw new Error('fake engine: unsupported `limit` — this double holds no bound');
}
return [...store.values()];
},
async update(_table: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
updateAttempts += 1;
const id = String(opts.where.id);
if (refuse.has(id)) throw new Error(`write refused for ${id}: permission denied on sys_metadata`);
const row = store.get(id);
if (!row) return { id: null };
Object.assign(row, data);
return { id };
},
};

return {
engine,
store,
refuseIds: (...ids: string[]) => ids.forEach((i) => refuse.add(i)),
updateAttempts: () => updateAttempts,
};
}

const orphan = (id: string): MetaRow => ({
id,
type: 'object',
name: `obj_${id}`,
organization_id: null,
package_id: null,
});

/** The one sentence fragment an operator greps for. */
const HEADLINE = 'orphaned metadata row(s) were NOT rebound';

afterEach(() => {
vi.restoreAllMocks();
});

function spyConsole() {
return {
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
};
}

describe('reassignOrphanedMetadata: a refused rebind is reported (#12981)', () => {
// ⚠️ CONTROL, not a pin. Before the repair this seam logged nothing at any
// level, so "a healthy adoption says nothing" was already true — it stays
// green in BOTH directions and is not ablation evidence. It is here so the
// pins below cannot pass on a seam that reports unconditionally.
it('CONTROL: an adoption in which every row rebinds reports nothing', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b')]);
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.reassignedCount).toBe(2);
expect(res.success).toBe(true);
expect(stub.updateAttempts()).toBe(2);
expect(spy.error).not.toHaveBeenCalled();
expect(spy.warn).not.toHaveBeenCalled();
});

it('reports a PARTIAL adoption — the run that still answers success: true', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b'), orphan('c')]);
stub.refuseIds('b', 'c');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

// Proof the writes were really attempted and really threw — otherwise
// every assertion below is about an adoption that never reached the seam.
expect(stub.updateAttempts()).toBe(3);
expect(stub.store.get('b')!.package_id).toBeNull();
expect(stub.store.get('c')!.package_id).toBeNull();

// ⛔ The response is UNCHANGED: this is the shape that reads healthy.
expect(res.success).toBe(true);
expect(res.reassignedCount).toBe(1);
expect(res.reassigned).toEqual([{ type: 'object', name: 'obj_a' }]);

// …and it is no longer the only thing that happened.
expect(spy.error).toHaveBeenCalledTimes(1);
const line = String(spy.error.mock.calls[0][0]);
expect(line).toContain(HEADLINE);
expect(line).toContain('2 of 3');
expect(line).toContain('app.base');
// The consequence and the fix, which AGENTS.md requires of this level.
expect(line).toContain('STILL orphans');
expect(line).toContain('Fix: restore write access');
// The driver's own sentence, so the operator is not left guessing why.
expect(line).toContain('permission denied on sys_metadata');
});

it('reports a TOTAL refusal, where the response already says success: false', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b')]);
stub.refuseIds('a', 'b');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.success).toBe(false);
expect(res.reassignedCount).toBe(0);
expect(spy.error).toHaveBeenCalledTimes(1);
expect(String(spy.error.mock.calls[0][0])).toContain('2 of 2');
});

it('states the degradation ONCE, not once per refused row', async () => {
const spy = spyConsole();
const ids = ['a', 'b', 'c', 'd', 'e', 'f'];
const stub = makeEngine(ids.map(orphan));
stub.refuseIds(...ids);
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

// Six refused writes, ONE operator-facing line — AGENTS.md's "say it
// once, at the first degradation, not once per failed write".
expect(stub.updateAttempts()).toBe(6);
expect(spy.error).toHaveBeenCalledTimes(1);
expect(String(spy.error.mock.calls[0][0])).toContain('6 of 6');
});

// ⚠️ CONTROL, not a pin — an INVARIANCE assertion. The pre-repair code
// returns exactly this too, so it stays green in both directions. It is
// here because the repair would be wrong if it changed control flow.
it('CONTROL: a refused row does not abort the rows that can still move', async () => {
spyConsole();
const stub = makeEngine([orphan('a'), orphan('b'), orphan('c')]);
stub.refuseIds('a');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.reassignedCount).toBe(2);
expect(stub.store.get('b')!.package_id).toBe('app.base');
expect(stub.store.get('c')!.package_id).toBe('app.base');
// The response shape is untouched: no `failedCount` was added.
expect(Object.keys(res).sort()).toEqual(
['reassigned', 'reassignedCount', 'success', 'targetPackageId'].sort(),
);
});
});
53 changes: 51 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17815,6 +17815,28 @@ export class ObjectStackProtocolImplementation implements
);

const reassigned: Array<{ type: string; name: string }> = [];
// [#12981] The refusal channel this loop used to lack entirely.
//
// The catch below is the card's defining shape, and this method is the
// sharpest instance of it in the file: a refused `update` was dropped
// whole -- not logged, not rethrown, not carried on the response -- and
// the return then reported `success: reassigned.length > 0`. So an
// adoption where 99 of 100 orphans were refused answered
// `{success: true, reassignedCount: 1}`, byte-identical in shape to a
// healthy run with one orphan to move, and the 99 stayed orphans with
// nothing anywhere recording that they had been tried. Nothing retries
// them and no later boot reconstructs the attempt.
//
// A COUNTER plus one report AFTER the loop, deliberately, and not a
// `console.error` inside the catch: AGENTS.md -> "Degradation log
// levels" says an operator-facing degradation is stated ONCE, at the
// first occurrence, not once per failed write -- and a refused
// `sys_metadata` write is refused for every row, so the per-row
// spelling would print one line per orphan in the environment. This is
// the same shape #12923's shared refusal accumulator takes at the other
// repaired seams of this family.
let refusedCount = 0;
let firstRefusal = '';
for (const row of orphans) {
try {
await this.engine.update(
Expand All@@ -17823,10 +17845,37 @@ export class ObjectStackProtocolImplementation implements
{ where: { id: row.id } },
);
reassigned.push({ type: row.type, name: row.name });
} catch {
/* skip a row that fails to update; report only what moved */
} catch (e: any) {
// Control flow is UNCHANGED: a row that cannot be rebound must
// not abort the adoption of the rows that can. Only the
// silence changes.
refusedCount += 1;
if (firstRefusal === '') firstRefusal = e?.message ?? String(e);
}
}
if (refusedCount > 0) {
// `error` and not `warn`, by the one question AGENTS.md turns this
// on -- after the degradation the system still looks normal from
// the outside while something it claims to have persisted did not
// land. It is the same verdict, on the same sink, that
// `recordPackageCommit` in this file already reaches for the
// `sys_metadata_commit` write, and the inverse of the one
// `clientFacingRowFailureText` records for its `console.warn`
// (there the row reports `success: false` and the counters
// reconcile, so nothing was silently dropped; here neither holds).
console.error(
`[Protocol] reassignOrphanedMetadata: ${refusedCount} of ${orphans.length} orphaned `
+ `metadata row(s) were NOT rebound to package '${request.targetPackageId}' -- the `
+ `update was REFUSED. The call still answers reassignedCount=${reassigned.length}`
+ `${reassigned.length > 0 ? ' with success: true' : ''}, so nothing looks broken, but `
+ 'those rows are STILL orphans: they keep `package_id` null or the `sys_metadata` '
+ 'sentinel, this environment has NOT converged on the package-first model (ADR-0070 '
+ 'D5 completes when an environment has no orphans), and nothing retries them. '
+ `First refusal: ${firstRefusal}. Fix: restore write access to \`sys_metadata\` for `
+ 'the system context and run the adoption again -- it is idempotent, rows already '
+ 'bound to a real package are left untouched.',
);
}
return {
success: reassigned.length > 0,
reassignedCount: reassigned.length,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Repair three durability swallows in metadata-protocol and service-storage — batch 7 of the #12981 worklist by os-steve · Pull Request #13725 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/durability-swallow-batch-7.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
---
'@objectstack/metadata-protocol': minor
'@objectstack/service-storage': minor
---

Report the refused writes three `catch { }` sites swallowed (#12981 batch 7)

Three tier-1 DARK sites from the #12981 swallow-family worklist, across two
packages. Control flow is unchanged at every one of them — none of these
failures should abort the operation it sits inside — but none of them is silent
any more.

**`metadata-protocol` — `reassignOrphanedMetadata` (the durability one).**
ADR-0070 D5's orphan-adoption loop dropped a refused `sys_metadata` update
whole: not logged, not rethrown, not carried on the response. The return line
reports `success: reassigned.length > 0`, so an adoption in which 99 of 100
orphans were refused answered `{ success: true, reassignedCount: 1 }` — a
response identical in shape to a healthy run with one orphan to move — while
the 99 stayed orphans, with nothing retrying them and no record that they had
been tried. The loop now counts refusals and states the degradation **once**
after the loop at `console.error`, naming the count, the target package, the
driver's own sentence and the fix. `error` and not this file's usual
`console.warn`, by the AGENTS.md question this turns on: the system keeps
looking normal while something it claims to have persisted did not land. It is
the verdict `recordPackageCommit` in the same file already reaches on the same
sink, and the inverse of the one `clientFacingRowFailureText` records for its
`console.warn` (there the row reports `success: false` and the counters
reconcile; here neither holds). The response shape is untouched — no
`failedCount` was added.

**`service-storage` — two sites at the tail of `StorageServicePlugin.start()`,
both functional, both `warn`.**

- The settings-namespace binding ended in `catch { }` with a comment naming
only one of the two outcomes it caught. The settings service being **absent**
(a bare kernel, where nothing ever claimed the admin UI could swap adapters)
is now resolved on its own line and stays correctly silent; a binding that
**fails with the service present** is reported, because `start()` otherwise
completes into a healthy-looking boot whose storage settings screen is wired
to nothing — an operator's adapter or credential change is saved and never
applied.
- The `storage/test` probe cleanup swallowed its own failure in
`catch { /* ignore */ }`. The result returned beside it reports the *probe's*
failure, which is a different failure: one stray `__objectstack_probe__/…`
key accrued per failed test and the only record of its name died with the
frame. The refused cleanup now names the key it left behind.

Both `service-storage` sites are `warn` on the merits, not by default: neither
is a durability degradation. Storage keeps serving from the adapter the
plugin's own options built, and the leaked probe object is inert content no
record references — AGENTS.md is explicit that escalating these is what makes
`error` unreadable. No sink type is changed at any of the three sites: the two
`service-storage` reports go to `PluginContext.logger`, whose `error` is
already non-optional, and `metadata-protocol` reports on `console`.

Each repaired seam is pinned by a test that fails if it goes quiet again, plus
absence-asserting controls — declared as controls — so a seam that reports
unconditionally cannot pass.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#12981 batch 7] A refused orphan rebind in `reassignOrphanedMetadata` must
* not be silent.
*
* ADR-0070 D5's adoption loop walked every package-less `sys_metadata` row and
* rebound it to a target base. Its `catch` was bare:
*
* } catch {
* /* skip a row that fails to update; report only what moved *\/
* }
*
* and the return line below it reports `success: reassigned.length > 0`. Put
* together, an adoption in which 99 of 100 orphans were REFUSED answered
* `{ success: true, reassignedCount: 1 }` — a response byte-identical in shape
* to a healthy run that had exactly one orphan to move. The 99 stayed orphans,
* nothing retried them, and no line anywhere recorded that they had been tried.
* That is the AGENTS.md durability shape exactly: the system keeps looking
* normal while something it claims to have persisted did not land.
*
* ## What this file pins, and what it deliberately does NOT
*
* ONLY the silence changes. The loop must still skip the refused row and adopt
* the rest — aborting the adoption over one unwritable row would strand the
* rows that CAN move — and the response shape is untouched, because adding a
* `failedCount` is a contract change this card does not carry. Both halves are
* asserted below rather than assumed.
*
* ## The level, stated so it can be argued with
*
* `console.error`, not `console.warn`, and not by default: `console.warn` is
* this file's overwhelming idiom (51 sites) and `clientFacingRowFailureText`
* records the discriminator in prose — it chose `warn` "deliberately — nothing
* claimed to be persisted was silently dropped (the row reports
* `success: false` and the counters reconcile)". Here NEITHER holds. The
* matching precedent is in this same file: `recordPackageCommit` already
* answers `console.error` for a refused `sys_metadata_commit` write under a
* publish that reports success.
*
* ## ONE line, not one per row
*
* AGENTS.md → "Degradation log levels" requires the report be stated once, at
* the first occurrence, not once per failed write — and a `sys_metadata` write
* that is refused is refused for every row, so the per-row spelling would print
* one line per orphan in the environment. The count is pinned, not just the
* presence.
*
* ⚠️ Two cases below are CONTROLS, not pins: they assert an ABSENCE against a
* seam that logged nothing at all before this repair, so they stay green in
* both directions by construction and are not evidence in an ablation.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
// [#5619] The producer's OWN write-verb dispatch decision, so this double
// cannot accept an `update` shape ObjectQL refuses. From
// `@objectstack/metadata-core` and NOT `@objectstack/objectql` — objectql
// depends on THIS package, so that import would close a cycle turbo rejects.
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { ObjectStackProtocolImplementation } from './protocol.js';

interface MetaRow {
id: string;
type: string;
name: string;
organization_id: string | null;
package_id: string | null;
}

/**
* Engine double over `sys_metadata` with a per-id refusal injector on `update`.
*
* The injection is keyed by ROW ID rather than being a global switch, because
* the defect's dangerous case is the PARTIAL one: an adoption where some rows
* move and some do not is the run that answers `success: true` while leaving
* orphans behind. A double that could only fail everything could not express
* it.
*/
function makeEngine(rows: MetaRow[]) {
const store = new Map(rows.map((r) => [r.id, { ...r }]));
const refuse = new Set<string>();
let updateAttempts = 0;

const engine = {
async find(table: string, opts?: { where?: Record<string, unknown>; limit?: number }) {
if (table !== 'sys_metadata') return [];
// This double implements NEITHER a `where` combinator NOR a bound,
// and REFUSES both rather than answering them silently. Every case
// in this file adopts env-wide orphans, so the producer passes
// `{ where: {} }` and no `limit`; the org-scoped `$or` branch and
// paging belong to tests that do not exist yet. A double looser
// than the engine it stands in for converts a green suite into no
// suite at all (#4434) — and the reason to refuse rather than
// approximate is that the approximation is invisible on the day the
// producer starts using the shape.
const where = opts?.where ?? {};
if (Object.keys(where).length > 0) {
throw new Error(`fake engine: unsupported where ${JSON.stringify(where)}`);
}
if (opts?.limit !== undefined) {
throw new Error('fake engine: unsupported `limit` — this double holds no bound');
}
return [...store.values()];
},
async update(_table: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
updateAttempts += 1;
const id = String(opts.where.id);
if (refuse.has(id)) throw new Error(`write refused for ${id}: permission denied on sys_metadata`);
const row = store.get(id);
if (!row) return { id: null };
Object.assign(row, data);
return { id };
},
};

return {
engine,
store,
refuseIds: (...ids: string[]) => ids.forEach((i) => refuse.add(i)),
updateAttempts: () => updateAttempts,
};
}

const orphan = (id: string): MetaRow => ({
id,
type: 'object',
name: `obj_${id}`,
organization_id: null,
package_id: null,
});

/** The one sentence fragment an operator greps for. */
const HEADLINE = 'orphaned metadata row(s) were NOT rebound';

afterEach(() => {
vi.restoreAllMocks();
});

function spyConsole() {
return {
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
};
}

describe('reassignOrphanedMetadata: a refused rebind is reported (#12981)', () => {
// ⚠️ CONTROL, not a pin. Before the repair this seam logged nothing at any
// level, so "a healthy adoption says nothing" was already true — it stays
// green in BOTH directions and is not ablation evidence. It is here so the
// pins below cannot pass on a seam that reports unconditionally.
it('CONTROL: an adoption in which every row rebinds reports nothing', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b')]);
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.reassignedCount).toBe(2);
expect(res.success).toBe(true);
expect(stub.updateAttempts()).toBe(2);
expect(spy.error).not.toHaveBeenCalled();
expect(spy.warn).not.toHaveBeenCalled();
});

it('reports a PARTIAL adoption — the run that still answers success: true', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b'), orphan('c')]);
stub.refuseIds('b', 'c');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

// Proof the writes were really attempted and really threw — otherwise
// every assertion below is about an adoption that never reached the seam.
expect(stub.updateAttempts()).toBe(3);
expect(stub.store.get('b')!.package_id).toBeNull();
expect(stub.store.get('c')!.package_id).toBeNull();

// ⛔ The response is UNCHANGED: this is the shape that reads healthy.
expect(res.success).toBe(true);
expect(res.reassignedCount).toBe(1);
expect(res.reassigned).toEqual([{ type: 'object', name: 'obj_a' }]);

// …and it is no longer the only thing that happened.
expect(spy.error).toHaveBeenCalledTimes(1);
const line = String(spy.error.mock.calls[0][0]);
expect(line).toContain(HEADLINE);
expect(line).toContain('2 of 3');
expect(line).toContain('app.base');
// The consequence and the fix, which AGENTS.md requires of this level.
expect(line).toContain('STILL orphans');
expect(line).toContain('Fix: restore write access');
// The driver's own sentence, so the operator is not left guessing why.
expect(line).toContain('permission denied on sys_metadata');
});

it('reports a TOTAL refusal, where the response already says success: false', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b')]);
stub.refuseIds('a', 'b');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.success).toBe(false);
expect(res.reassignedCount).toBe(0);
expect(spy.error).toHaveBeenCalledTimes(1);
expect(String(spy.error.mock.calls[0][0])).toContain('2 of 2');
});

it('states the degradation ONCE, not once per refused row', async () => {
const spy = spyConsole();
const ids = ['a', 'b', 'c', 'd', 'e', 'f'];
const stub = makeEngine(ids.map(orphan));
stub.refuseIds(...ids);
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

// Six refused writes, ONE operator-facing line — AGENTS.md's "say it
// once, at the first degradation, not once per failed write".
expect(stub.updateAttempts()).toBe(6);
expect(spy.error).toHaveBeenCalledTimes(1);
expect(String(spy.error.mock.calls[0][0])).toContain('6 of 6');
});

// ⚠️ CONTROL, not a pin — an INVARIANCE assertion. The pre-repair code
// returns exactly this too, so it stays green in both directions. It is
// here because the repair would be wrong if it changed control flow.
it('CONTROL: a refused row does not abort the rows that can still move', async () => {
spyConsole();
const stub = makeEngine([orphan('a'), orphan('b'), orphan('c')]);
stub.refuseIds('a');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.reassignedCount).toBe(2);
expect(stub.store.get('b')!.package_id).toBe('app.base');
expect(stub.store.get('c')!.package_id).toBe('app.base');
// The response shape is untouched: no `failedCount` was added.
expect(Object.keys(res).sort()).toEqual(
['reassigned', 'reassignedCount', 'success', 'targetPackageId'].sort(),
);
});
});
53 changes: 51 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17815,6 +17815,28 @@ export class ObjectStackProtocolImplementation implements
);

const reassigned: Array<{ type: string; name: string }> = [];
// [#12981] The refusal channel this loop used to lack entirely.
//
// The catch below is the card's defining shape, and this method is the
// sharpest instance of it in the file: a refused `update` was dropped
// whole -- not logged, not rethrown, not carried on the response -- and
// the return then reported `success: reassigned.length > 0`. So an
// adoption where 99 of 100 orphans were refused answered
// `{success: true, reassignedCount: 1}`, byte-identical in shape to a
// healthy run with one orphan to move, and the 99 stayed orphans with
// nothing anywhere recording that they had been tried. Nothing retries
// them and no later boot reconstructs the attempt.
//
// A COUNTER plus one report AFTER the loop, deliberately, and not a
// `console.error` inside the catch: AGENTS.md -> "Degradation log
// levels" says an operator-facing degradation is stated ONCE, at the
// first occurrence, not once per failed write -- and a refused
// `sys_metadata` write is refused for every row, so the per-row
// spelling would print one line per orphan in the environment. This is
// the same shape #12923's shared refusal accumulator takes at the other
// repaired seams of this family.
let refusedCount = 0;
let firstRefusal = '';
for (const row of orphans) {
try {
await this.engine.update(
Expand All@@ -17823,10 +17845,37 @@ export class ObjectStackProtocolImplementation implements
{ where: { id: row.id } },
);
reassigned.push({ type: row.type, name: row.name });
} catch {
/* skip a row that fails to update; report only what moved */
} catch (e: any) {
// Control flow is UNCHANGED: a row that cannot be rebound must
// not abort the adoption of the rows that can. Only the
// silence changes.
refusedCount += 1;
if (firstRefusal === '') firstRefusal = e?.message ?? String(e);
}
}
if (refusedCount > 0) {
// `error` and not `warn`, by the one question AGENTS.md turns this
// on -- after the degradation the system still looks normal from
// the outside while something it claims to have persisted did not
// land. It is the same verdict, on the same sink, that
// `recordPackageCommit` in this file already reaches for the
// `sys_metadata_commit` write, and the inverse of the one
// `clientFacingRowFailureText` records for its `console.warn`
// (there the row reports `success: false` and the counters
// reconcile, so nothing was silently dropped; here neither holds).
console.error(
`[Protocol] reassignOrphanedMetadata: ${refusedCount} of ${orphans.length} orphaned `
+ `metadata row(s) were NOT rebound to package '${request.targetPackageId}' -- the `
+ `update was REFUSED. The call still answers reassignedCount=${reassigned.length}`
+ `${reassigned.length > 0 ? ' with success: true' : ''}, so nothing looks broken, but `
+ 'those rows are STILL orphans: they keep `package_id` null or the `sys_metadata` '
+ 'sentinel, this environment has NOT converged on the package-first model (ADR-0070 '
+ 'D5 completes when an environment has no orphans), and nothing retries them. '
+ `First refusal: ${firstRefusal}. Fix: restore write access to \`sys_metadata\` for `
+ 'the system context and run the adoption again -- it is idempotent, rows already '
+ 'bound to a real package are left untouched.',
);
}
return {
success: reassigned.length > 0,
reassignedCount: reassigned.length,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Repair three durability swallows in metadata-protocol and service-storage — batch 7 of the #12981 worklist by os-steve · Pull Request #13725 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/durability-swallow-batch-7.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
---
'@objectstack/metadata-protocol': minor
'@objectstack/service-storage': minor
---

Report the refused writes three `catch { }` sites swallowed (#12981 batch 7)

Three tier-1 DARK sites from the #12981 swallow-family worklist, across two
packages. Control flow is unchanged at every one of them — none of these
failures should abort the operation it sits inside — but none of them is silent
any more.

**`metadata-protocol` — `reassignOrphanedMetadata` (the durability one).**
ADR-0070 D5's orphan-adoption loop dropped a refused `sys_metadata` update
whole: not logged, not rethrown, not carried on the response. The return line
reports `success: reassigned.length > 0`, so an adoption in which 99 of 100
orphans were refused answered `{ success: true, reassignedCount: 1 }` — a
response identical in shape to a healthy run with one orphan to move — while
the 99 stayed orphans, with nothing retrying them and no record that they had
been tried. The loop now counts refusals and states the degradation **once**
after the loop at `console.error`, naming the count, the target package, the
driver's own sentence and the fix. `error` and not this file's usual
`console.warn`, by the AGENTS.md question this turns on: the system keeps
looking normal while something it claims to have persisted did not land. It is
the verdict `recordPackageCommit` in the same file already reaches on the same
sink, and the inverse of the one `clientFacingRowFailureText` records for its
`console.warn` (there the row reports `success: false` and the counters
reconcile; here neither holds). The response shape is untouched — no
`failedCount` was added.

**`service-storage` — two sites at the tail of `StorageServicePlugin.start()`,
both functional, both `warn`.**

- The settings-namespace binding ended in `catch { }` with a comment naming
only one of the two outcomes it caught. The settings service being **absent**
(a bare kernel, where nothing ever claimed the admin UI could swap adapters)
is now resolved on its own line and stays correctly silent; a binding that
**fails with the service present** is reported, because `start()` otherwise
completes into a healthy-looking boot whose storage settings screen is wired
to nothing — an operator's adapter or credential change is saved and never
applied.
- The `storage/test` probe cleanup swallowed its own failure in
`catch { /* ignore */ }`. The result returned beside it reports the *probe's*
failure, which is a different failure: one stray `__objectstack_probe__/…`
key accrued per failed test and the only record of its name died with the
frame. The refused cleanup now names the key it left behind.

Both `service-storage` sites are `warn` on the merits, not by default: neither
is a durability degradation. Storage keeps serving from the adapter the
plugin's own options built, and the leaked probe object is inert content no
record references — AGENTS.md is explicit that escalating these is what makes
`error` unreadable. No sink type is changed at any of the three sites: the two
`service-storage` reports go to `PluginContext.logger`, whose `error` is
already non-optional, and `metadata-protocol` reports on `console`.

Each repaired seam is pinned by a test that fails if it goes quiet again, plus
absence-asserting controls — declared as controls — so a seam that reports
unconditionally cannot pass.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#12981 batch 7] A refused orphan rebind in `reassignOrphanedMetadata` must
* not be silent.
*
* ADR-0070 D5's adoption loop walked every package-less `sys_metadata` row and
* rebound it to a target base. Its `catch` was bare:
*
* } catch {
* /* skip a row that fails to update; report only what moved *\/
* }
*
* and the return line below it reports `success: reassigned.length > 0`. Put
* together, an adoption in which 99 of 100 orphans were REFUSED answered
* `{ success: true, reassignedCount: 1 }` — a response byte-identical in shape
* to a healthy run that had exactly one orphan to move. The 99 stayed orphans,
* nothing retried them, and no line anywhere recorded that they had been tried.
* That is the AGENTS.md durability shape exactly: the system keeps looking
* normal while something it claims to have persisted did not land.
*
* ## What this file pins, and what it deliberately does NOT
*
* ONLY the silence changes. The loop must still skip the refused row and adopt
* the rest — aborting the adoption over one unwritable row would strand the
* rows that CAN move — and the response shape is untouched, because adding a
* `failedCount` is a contract change this card does not carry. Both halves are
* asserted below rather than assumed.
*
* ## The level, stated so it can be argued with
*
* `console.error`, not `console.warn`, and not by default: `console.warn` is
* this file's overwhelming idiom (51 sites) and `clientFacingRowFailureText`
* records the discriminator in prose — it chose `warn` "deliberately — nothing
* claimed to be persisted was silently dropped (the row reports
* `success: false` and the counters reconcile)". Here NEITHER holds. The
* matching precedent is in this same file: `recordPackageCommit` already
* answers `console.error` for a refused `sys_metadata_commit` write under a
* publish that reports success.
*
* ## ONE line, not one per row
*
* AGENTS.md → "Degradation log levels" requires the report be stated once, at
* the first occurrence, not once per failed write — and a `sys_metadata` write
* that is refused is refused for every row, so the per-row spelling would print
* one line per orphan in the environment. The count is pinned, not just the
* presence.
*
* ⚠️ Two cases below are CONTROLS, not pins: they assert an ABSENCE against a
* seam that logged nothing at all before this repair, so they stay green in
* both directions by construction and are not evidence in an ablation.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
// [#5619] The producer's OWN write-verb dispatch decision, so this double
// cannot accept an `update` shape ObjectQL refuses. From
// `@objectstack/metadata-core` and NOT `@objectstack/objectql` — objectql
// depends on THIS package, so that import would close a cycle turbo rejects.
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { ObjectStackProtocolImplementation } from './protocol.js';

interface MetaRow {
id: string;
type: string;
name: string;
organization_id: string | null;
package_id: string | null;
}

/**
* Engine double over `sys_metadata` with a per-id refusal injector on `update`.
*
* The injection is keyed by ROW ID rather than being a global switch, because
* the defect's dangerous case is the PARTIAL one: an adoption where some rows
* move and some do not is the run that answers `success: true` while leaving
* orphans behind. A double that could only fail everything could not express
* it.
*/
function makeEngine(rows: MetaRow[]) {
const store = new Map(rows.map((r) => [r.id, { ...r }]));
const refuse = new Set<string>();
let updateAttempts = 0;

const engine = {
async find(table: string, opts?: { where?: Record<string, unknown>; limit?: number }) {
if (table !== 'sys_metadata') return [];
// This double implements NEITHER a `where` combinator NOR a bound,
// and REFUSES both rather than answering them silently. Every case
// in this file adopts env-wide orphans, so the producer passes
// `{ where: {} }` and no `limit`; the org-scoped `$or` branch and
// paging belong to tests that do not exist yet. A double looser
// than the engine it stands in for converts a green suite into no
// suite at all (#4434) — and the reason to refuse rather than
// approximate is that the approximation is invisible on the day the
// producer starts using the shape.
const where = opts?.where ?? {};
if (Object.keys(where).length > 0) {
throw new Error(`fake engine: unsupported where ${JSON.stringify(where)}`);
}
if (opts?.limit !== undefined) {
throw new Error('fake engine: unsupported `limit` — this double holds no bound');
}
return [...store.values()];
},
async update(_table: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
updateAttempts += 1;
const id = String(opts.where.id);
if (refuse.has(id)) throw new Error(`write refused for ${id}: permission denied on sys_metadata`);
const row = store.get(id);
if (!row) return { id: null };
Object.assign(row, data);
return { id };
},
};

return {
engine,
store,
refuseIds: (...ids: string[]) => ids.forEach((i) => refuse.add(i)),
updateAttempts: () => updateAttempts,
};
}

const orphan = (id: string): MetaRow => ({
id,
type: 'object',
name: `obj_${id}`,
organization_id: null,
package_id: null,
});

/** The one sentence fragment an operator greps for. */
const HEADLINE = 'orphaned metadata row(s) were NOT rebound';

afterEach(() => {
vi.restoreAllMocks();
});

function spyConsole() {
return {
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
};
}

describe('reassignOrphanedMetadata: a refused rebind is reported (#12981)', () => {
// ⚠️ CONTROL, not a pin. Before the repair this seam logged nothing at any
// level, so "a healthy adoption says nothing" was already true — it stays
// green in BOTH directions and is not ablation evidence. It is here so the
// pins below cannot pass on a seam that reports unconditionally.
it('CONTROL: an adoption in which every row rebinds reports nothing', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b')]);
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.reassignedCount).toBe(2);
expect(res.success).toBe(true);
expect(stub.updateAttempts()).toBe(2);
expect(spy.error).not.toHaveBeenCalled();
expect(spy.warn).not.toHaveBeenCalled();
});

it('reports a PARTIAL adoption — the run that still answers success: true', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b'), orphan('c')]);
stub.refuseIds('b', 'c');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

// Proof the writes were really attempted and really threw — otherwise
// every assertion below is about an adoption that never reached the seam.
expect(stub.updateAttempts()).toBe(3);
expect(stub.store.get('b')!.package_id).toBeNull();
expect(stub.store.get('c')!.package_id).toBeNull();

// ⛔ The response is UNCHANGED: this is the shape that reads healthy.
expect(res.success).toBe(true);
expect(res.reassignedCount).toBe(1);
expect(res.reassigned).toEqual([{ type: 'object', name: 'obj_a' }]);

// …and it is no longer the only thing that happened.
expect(spy.error).toHaveBeenCalledTimes(1);
const line = String(spy.error.mock.calls[0][0]);
expect(line).toContain(HEADLINE);
expect(line).toContain('2 of 3');
expect(line).toContain('app.base');
// The consequence and the fix, which AGENTS.md requires of this level.
expect(line).toContain('STILL orphans');
expect(line).toContain('Fix: restore write access');
// The driver's own sentence, so the operator is not left guessing why.
expect(line).toContain('permission denied on sys_metadata');
});

it('reports a TOTAL refusal, where the response already says success: false', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b')]);
stub.refuseIds('a', 'b');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.success).toBe(false);
expect(res.reassignedCount).toBe(0);
expect(spy.error).toHaveBeenCalledTimes(1);
expect(String(spy.error.mock.calls[0][0])).toContain('2 of 2');
});

it('states the degradation ONCE, not once per refused row', async () => {
const spy = spyConsole();
const ids = ['a', 'b', 'c', 'd', 'e', 'f'];
const stub = makeEngine(ids.map(orphan));
stub.refuseIds(...ids);
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

// Six refused writes, ONE operator-facing line — AGENTS.md's "say it
// once, at the first degradation, not once per failed write".
expect(stub.updateAttempts()).toBe(6);
expect(spy.error).toHaveBeenCalledTimes(1);
expect(String(spy.error.mock.calls[0][0])).toContain('6 of 6');
});

// ⚠️ CONTROL, not a pin — an INVARIANCE assertion. The pre-repair code
// returns exactly this too, so it stays green in both directions. It is
// here because the repair would be wrong if it changed control flow.
it('CONTROL: a refused row does not abort the rows that can still move', async () => {
spyConsole();
const stub = makeEngine([orphan('a'), orphan('b'), orphan('c')]);
stub.refuseIds('a');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.reassignedCount).toBe(2);
expect(stub.store.get('b')!.package_id).toBe('app.base');
expect(stub.store.get('c')!.package_id).toBe('app.base');
// The response shape is untouched: no `failedCount` was added.
expect(Object.keys(res).sort()).toEqual(
['reassigned', 'reassignedCount', 'success', 'targetPackageId'].sort(),
);
});
});
53 changes: 51 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17815,6 +17815,28 @@ export class ObjectStackProtocolImplementation implements
);

const reassigned: Array<{ type: string; name: string }> = [];
// [#12981] The refusal channel this loop used to lack entirely.
//
// The catch below is the card's defining shape, and this method is the
// sharpest instance of it in the file: a refused `update` was dropped
// whole -- not logged, not rethrown, not carried on the response -- and
// the return then reported `success: reassigned.length > 0`. So an
// adoption where 99 of 100 orphans were refused answered
// `{success: true, reassignedCount: 1}`, byte-identical in shape to a
// healthy run with one orphan to move, and the 99 stayed orphans with
// nothing anywhere recording that they had been tried. Nothing retries
// them and no later boot reconstructs the attempt.
//
// A COUNTER plus one report AFTER the loop, deliberately, and not a
// `console.error` inside the catch: AGENTS.md -> "Degradation log
// levels" says an operator-facing degradation is stated ONCE, at the
// first occurrence, not once per failed write -- and a refused
// `sys_metadata` write is refused for every row, so the per-row
// spelling would print one line per orphan in the environment. This is
// the same shape #12923's shared refusal accumulator takes at the other
// repaired seams of this family.
let refusedCount = 0;
let firstRefusal = '';
for (const row of orphans) {
try {
await this.engine.update(
Expand All@@ -17823,10 +17845,37 @@ export class ObjectStackProtocolImplementation implements
{ where: { id: row.id } },
);
reassigned.push({ type: row.type, name: row.name });
} catch {
/* skip a row that fails to update; report only what moved */
} catch (e: any) {
// Control flow is UNCHANGED: a row that cannot be rebound must
// not abort the adoption of the rows that can. Only the
// silence changes.
refusedCount += 1;
if (firstRefusal === '') firstRefusal = e?.message ?? String(e);
}
}
if (refusedCount > 0) {
// `error` and not `warn`, by the one question AGENTS.md turns this
// on -- after the degradation the system still looks normal from
// the outside while something it claims to have persisted did not
// land. It is the same verdict, on the same sink, that
// `recordPackageCommit` in this file already reaches for the
// `sys_metadata_commit` write, and the inverse of the one
// `clientFacingRowFailureText` records for its `console.warn`
// (there the row reports `success: false` and the counters
// reconcile, so nothing was silently dropped; here neither holds).
console.error(
`[Protocol] reassignOrphanedMetadata: ${refusedCount} of ${orphans.length} orphaned `
+ `metadata row(s) were NOT rebound to package '${request.targetPackageId}' -- the `
+ `update was REFUSED. The call still answers reassignedCount=${reassigned.length}`
+ `${reassigned.length > 0 ? ' with success: true' : ''}, so nothing looks broken, but `
+ 'those rows are STILL orphans: they keep `package_id` null or the `sys_metadata` '
+ 'sentinel, this environment has NOT converged on the package-first model (ADR-0070 '
+ 'D5 completes when an environment has no orphans), and nothing retries them. '
+ `First refusal: ${firstRefusal}. Fix: restore write access to \`sys_metadata\` for `
+ 'the system context and run the adoption again -- it is idempotent, rows already '
+ 'bound to a real package are left untouched.',
);
}
return {
success: reassigned.length > 0,
reassignedCount: reassigned.length,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Repair three durability swallows in metadata-protocol and service-storage — batch 7 of the #12981 worklist by os-steve · Pull Request #13725 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/durability-swallow-batch-7.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
---
'@objectstack/metadata-protocol': minor
'@objectstack/service-storage': minor
---

Report the refused writes three `catch { }` sites swallowed (#12981 batch 7)

Three tier-1 DARK sites from the #12981 swallow-family worklist, across two
packages. Control flow is unchanged at every one of them — none of these
failures should abort the operation it sits inside — but none of them is silent
any more.

**`metadata-protocol` — `reassignOrphanedMetadata` (the durability one).**
ADR-0070 D5's orphan-adoption loop dropped a refused `sys_metadata` update
whole: not logged, not rethrown, not carried on the response. The return line
reports `success: reassigned.length > 0`, so an adoption in which 99 of 100
orphans were refused answered `{ success: true, reassignedCount: 1 }` — a
response identical in shape to a healthy run with one orphan to move — while
the 99 stayed orphans, with nothing retrying them and no record that they had
been tried. The loop now counts refusals and states the degradation **once**
after the loop at `console.error`, naming the count, the target package, the
driver's own sentence and the fix. `error` and not this file's usual
`console.warn`, by the AGENTS.md question this turns on: the system keeps
looking normal while something it claims to have persisted did not land. It is
the verdict `recordPackageCommit` in the same file already reaches on the same
sink, and the inverse of the one `clientFacingRowFailureText` records for its
`console.warn` (there the row reports `success: false` and the counters
reconcile; here neither holds). The response shape is untouched — no
`failedCount` was added.

**`service-storage` — two sites at the tail of `StorageServicePlugin.start()`,
both functional, both `warn`.**

- The settings-namespace binding ended in `catch { }` with a comment naming
only one of the two outcomes it caught. The settings service being **absent**
(a bare kernel, where nothing ever claimed the admin UI could swap adapters)
is now resolved on its own line and stays correctly silent; a binding that
**fails with the service present** is reported, because `start()` otherwise
completes into a healthy-looking boot whose storage settings screen is wired
to nothing — an operator's adapter or credential change is saved and never
applied.
- The `storage/test` probe cleanup swallowed its own failure in
`catch { /* ignore */ }`. The result returned beside it reports the *probe's*
failure, which is a different failure: one stray `__objectstack_probe__/…`
key accrued per failed test and the only record of its name died with the
frame. The refused cleanup now names the key it left behind.

Both `service-storage` sites are `warn` on the merits, not by default: neither
is a durability degradation. Storage keeps serving from the adapter the
plugin's own options built, and the leaked probe object is inert content no
record references — AGENTS.md is explicit that escalating these is what makes
`error` unreadable. No sink type is changed at any of the three sites: the two
`service-storage` reports go to `PluginContext.logger`, whose `error` is
already non-optional, and `metadata-protocol` reports on `console`.

Each repaired seam is pinned by a test that fails if it goes quiet again, plus
absence-asserting controls — declared as controls — so a seam that reports
unconditionally cannot pass.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#12981 batch 7] A refused orphan rebind in `reassignOrphanedMetadata` must
* not be silent.
*
* ADR-0070 D5's adoption loop walked every package-less `sys_metadata` row and
* rebound it to a target base. Its `catch` was bare:
*
* } catch {
* /* skip a row that fails to update; report only what moved *\/
* }
*
* and the return line below it reports `success: reassigned.length > 0`. Put
* together, an adoption in which 99 of 100 orphans were REFUSED answered
* `{ success: true, reassignedCount: 1 }` — a response byte-identical in shape
* to a healthy run that had exactly one orphan to move. The 99 stayed orphans,
* nothing retried them, and no line anywhere recorded that they had been tried.
* That is the AGENTS.md durability shape exactly: the system keeps looking
* normal while something it claims to have persisted did not land.
*
* ## What this file pins, and what it deliberately does NOT
*
* ONLY the silence changes. The loop must still skip the refused row and adopt
* the rest — aborting the adoption over one unwritable row would strand the
* rows that CAN move — and the response shape is untouched, because adding a
* `failedCount` is a contract change this card does not carry. Both halves are
* asserted below rather than assumed.
*
* ## The level, stated so it can be argued with
*
* `console.error`, not `console.warn`, and not by default: `console.warn` is
* this file's overwhelming idiom (51 sites) and `clientFacingRowFailureText`
* records the discriminator in prose — it chose `warn` "deliberately — nothing
* claimed to be persisted was silently dropped (the row reports
* `success: false` and the counters reconcile)". Here NEITHER holds. The
* matching precedent is in this same file: `recordPackageCommit` already
* answers `console.error` for a refused `sys_metadata_commit` write under a
* publish that reports success.
*
* ## ONE line, not one per row
*
* AGENTS.md → "Degradation log levels" requires the report be stated once, at
* the first occurrence, not once per failed write — and a `sys_metadata` write
* that is refused is refused for every row, so the per-row spelling would print
* one line per orphan in the environment. The count is pinned, not just the
* presence.
*
* ⚠️ Two cases below are CONTROLS, not pins: they assert an ABSENCE against a
* seam that logged nothing at all before this repair, so they stay green in
* both directions by construction and are not evidence in an ablation.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
// [#5619] The producer's OWN write-verb dispatch decision, so this double
// cannot accept an `update` shape ObjectQL refuses. From
// `@objectstack/metadata-core` and NOT `@objectstack/objectql` — objectql
// depends on THIS package, so that import would close a cycle turbo rejects.
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { ObjectStackProtocolImplementation } from './protocol.js';

interface MetaRow {
id: string;
type: string;
name: string;
organization_id: string | null;
package_id: string | null;
}

/**
* Engine double over `sys_metadata` with a per-id refusal injector on `update`.
*
* The injection is keyed by ROW ID rather than being a global switch, because
* the defect's dangerous case is the PARTIAL one: an adoption where some rows
* move and some do not is the run that answers `success: true` while leaving
* orphans behind. A double that could only fail everything could not express
* it.
*/
function makeEngine(rows: MetaRow[]) {
const store = new Map(rows.map((r) => [r.id, { ...r }]));
const refuse = new Set<string>();
let updateAttempts = 0;

const engine = {
async find(table: string, opts?: { where?: Record<string, unknown>; limit?: number }) {
if (table !== 'sys_metadata') return [];
// This double implements NEITHER a `where` combinator NOR a bound,
// and REFUSES both rather than answering them silently. Every case
// in this file adopts env-wide orphans, so the producer passes
// `{ where: {} }` and no `limit`; the org-scoped `$or` branch and
// paging belong to tests that do not exist yet. A double looser
// than the engine it stands in for converts a green suite into no
// suite at all (#4434) — and the reason to refuse rather than
// approximate is that the approximation is invisible on the day the
// producer starts using the shape.
const where = opts?.where ?? {};
if (Object.keys(where).length > 0) {
throw new Error(`fake engine: unsupported where ${JSON.stringify(where)}`);
}
if (opts?.limit !== undefined) {
throw new Error('fake engine: unsupported `limit` — this double holds no bound');
}
return [...store.values()];
},
async update(_table: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
updateAttempts += 1;
const id = String(opts.where.id);
if (refuse.has(id)) throw new Error(`write refused for ${id}: permission denied on sys_metadata`);
const row = store.get(id);
if (!row) return { id: null };
Object.assign(row, data);
return { id };
},
};

return {
engine,
store,
refuseIds: (...ids: string[]) => ids.forEach((i) => refuse.add(i)),
updateAttempts: () => updateAttempts,
};
}

const orphan = (id: string): MetaRow => ({
id,
type: 'object',
name: `obj_${id}`,
organization_id: null,
package_id: null,
});

/** The one sentence fragment an operator greps for. */
const HEADLINE = 'orphaned metadata row(s) were NOT rebound';

afterEach(() => {
vi.restoreAllMocks();
});

function spyConsole() {
return {
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
};
}

describe('reassignOrphanedMetadata: a refused rebind is reported (#12981)', () => {
// ⚠️ CONTROL, not a pin. Before the repair this seam logged nothing at any
// level, so "a healthy adoption says nothing" was already true — it stays
// green in BOTH directions and is not ablation evidence. It is here so the
// pins below cannot pass on a seam that reports unconditionally.
it('CONTROL: an adoption in which every row rebinds reports nothing', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b')]);
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.reassignedCount).toBe(2);
expect(res.success).toBe(true);
expect(stub.updateAttempts()).toBe(2);
expect(spy.error).not.toHaveBeenCalled();
expect(spy.warn).not.toHaveBeenCalled();
});

it('reports a PARTIAL adoption — the run that still answers success: true', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b'), orphan('c')]);
stub.refuseIds('b', 'c');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

// Proof the writes were really attempted and really threw — otherwise
// every assertion below is about an adoption that never reached the seam.
expect(stub.updateAttempts()).toBe(3);
expect(stub.store.get('b')!.package_id).toBeNull();
expect(stub.store.get('c')!.package_id).toBeNull();

// ⛔ The response is UNCHANGED: this is the shape that reads healthy.
expect(res.success).toBe(true);
expect(res.reassignedCount).toBe(1);
expect(res.reassigned).toEqual([{ type: 'object', name: 'obj_a' }]);

// …and it is no longer the only thing that happened.
expect(spy.error).toHaveBeenCalledTimes(1);
const line = String(spy.error.mock.calls[0][0]);
expect(line).toContain(HEADLINE);
expect(line).toContain('2 of 3');
expect(line).toContain('app.base');
// The consequence and the fix, which AGENTS.md requires of this level.
expect(line).toContain('STILL orphans');
expect(line).toContain('Fix: restore write access');
// The driver's own sentence, so the operator is not left guessing why.
expect(line).toContain('permission denied on sys_metadata');
});

it('reports a TOTAL refusal, where the response already says success: false', async () => {
const spy = spyConsole();
const stub = makeEngine([orphan('a'), orphan('b')]);
stub.refuseIds('a', 'b');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.success).toBe(false);
expect(res.reassignedCount).toBe(0);
expect(spy.error).toHaveBeenCalledTimes(1);
expect(String(spy.error.mock.calls[0][0])).toContain('2 of 2');
});

it('states the degradation ONCE, not once per refused row', async () => {
const spy = spyConsole();
const ids = ['a', 'b', 'c', 'd', 'e', 'f'];
const stub = makeEngine(ids.map(orphan));
stub.refuseIds(...ids);
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

// Six refused writes, ONE operator-facing line — AGENTS.md's "say it
// once, at the first degradation, not once per failed write".
expect(stub.updateAttempts()).toBe(6);
expect(spy.error).toHaveBeenCalledTimes(1);
expect(String(spy.error.mock.calls[0][0])).toContain('6 of 6');
});

// ⚠️ CONTROL, not a pin — an INVARIANCE assertion. The pre-repair code
// returns exactly this too, so it stays green in both directions. It is
// here because the repair would be wrong if it changed control flow.
it('CONTROL: a refused row does not abort the rows that can still move', async () => {
spyConsole();
const stub = makeEngine([orphan('a'), orphan('b'), orphan('c')]);
stub.refuseIds('a');
const protocol = new ObjectStackProtocolImplementation(stub.engine as never);

const res = await protocol.reassignOrphanedMetadata({ targetPackageId: 'app.base' });

expect(res.reassignedCount).toBe(2);
expect(stub.store.get('b')!.package_id).toBe('app.base');
expect(stub.store.get('c')!.package_id).toBe('app.base');
// The response shape is untouched: no `failedCount` was added.
expect(Object.keys(res).sort()).toEqual(
['reassigned', 'reassignedCount', 'success', 'targetPackageId'].sort(),
);
});
});
53 changes: 51 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17815,6 +17815,28 @@ export class ObjectStackProtocolImplementation implements
);

const reassigned: Array<{ type: string; name: string }> = [];
// [#12981] The refusal channel this loop used to lack entirely.
//
// The catch below is the card's defining shape, and this method is the
// sharpest instance of it in the file: a refused `update` was dropped
// whole -- not logged, not rethrown, not carried on the response -- and
// the return then reported `success: reassigned.length > 0`. So an
// adoption where 99 of 100 orphans were refused answered
// `{success: true, reassignedCount: 1}`, byte-identical in shape to a
// healthy run with one orphan to move, and the 99 stayed orphans with
// nothing anywhere recording that they had been tried. Nothing retries
// them and no later boot reconstructs the attempt.
//
// A COUNTER plus one report AFTER the loop, deliberately, and not a
// `console.error` inside the catch: AGENTS.md -> "Degradation log
// levels" says an operator-facing degradation is stated ONCE, at the
// first occurrence, not once per failed write -- and a refused
// `sys_metadata` write is refused for every row, so the per-row
// spelling would print one line per orphan in the environment. This is
// the same shape #12923's shared refusal accumulator takes at the other
// repaired seams of this family.
let refusedCount = 0;
let firstRefusal = '';
for (const row of orphans) {
try {
await this.engine.update(
Expand All@@ -17823,10 +17845,37 @@ export class ObjectStackProtocolImplementation implements
{ where: { id: row.id } },
);
reassigned.push({ type: row.type, name: row.name });
} catch {
/* skip a row that fails to update; report only what moved */
} catch (e: any) {
// Control flow is UNCHANGED: a row that cannot be rebound must
// not abort the adoption of the rows that can. Only the
// silence changes.
refusedCount += 1;
if (firstRefusal === '') firstRefusal = e?.message ?? String(e);
}
}
if (refusedCount > 0) {
// `error` and not `warn`, by the one question AGENTS.md turns this
// on -- after the degradation the system still looks normal from
// the outside while something it claims to have persisted did not
// land. It is the same verdict, on the same sink, that
// `recordPackageCommit` in this file already reaches for the
// `sys_metadata_commit` write, and the inverse of the one
// `clientFacingRowFailureText` records for its `console.warn`
// (there the row reports `success: false` and the counters
// reconcile, so nothing was silently dropped; here neither holds).
console.error(
`[Protocol] reassignOrphanedMetadata: ${refusedCount} of ${orphans.length} orphaned `
+ `metadata row(s) were NOT rebound to package '${request.targetPackageId}' -- the `
+ `update was REFUSED. The call still answers reassignedCount=${reassigned.length}`
+ `${reassigned.length > 0 ? ' with success: true' : ''}, so nothing looks broken, but `
+ 'those rows are STILL orphans: they keep `package_id` null or the `sys_metadata` '
+ 'sentinel, this environment has NOT converged on the package-first model (ADR-0070 '
+ 'D5 completes when an environment has no orphans), and nothing retries them. '
+ `First refusal: ${firstRefusal}. Fix: restore write access to \`sys_metadata\` for `
+ 'the system context and run the adoption again -- it is idempotent, rows already '
+ 'bound to a real package are left untouched.',
);
}
return {
success: reassigned.length > 0,
reassignedCount: reassigned.length,
Expand Down
Loading
Loading