Skip to content

Commit a928e88

Browse files
committed
fix(metadata-protocol): write saved overlays through the SchemaRegistry so they are dispatchable immediately (#4521)
A just-saved overlay was listed but not dispatchable for a short window: saveMetaItem only wrote the registry through for `object`, so every other overlay type reached it solely via the READ-side hydration in getMetaItems — the listing call is what repaired the dispatch path. resolveRouteActionDeclaration reads the registry, so `PUT /meta/action/x` followed by `POST /actions/<obj>/x` answered the ADR-0110 "has no declaration" 404 until someone listed the type. - Extract the read-side hydration rule (ADR-0010 §3.3 protection graft, ADR-0048 package-scoped artifact lookup) into hydrateOverlayIntoRegistry and share it between getMetaItems and the new applyRegistryWriteThrough. - Call the write-through from saveMetaItem (publish mode), runPublishSideEffects (draft promotion), and rollbackMetaItem — for EVERY overlay type, with the same environmentId scoping gate the read carries. - Boundaries pinned by tests: drafts never leak into the live registry, ADR-0110's 404 for a genuinely absent declaration stands, and DELETE still restores the packaged artifact (the overlay is a plain-key shadow). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5
1 parent 60ae58e commit a928e88

4 files changed

Lines changed: 373 additions & 34 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): a just-saved overlay is dispatchable immediately, not after the next listing (#4521)
6+
7+
The #4432 F1 verification found that immediately after a successful
8+
`PUT /api/v1/meta/action/<name>`, `GET /api/v1/meta/action` already listed the
9+
overlay while `POST /api/v1/actions/<object>/<name>` answered the ADR-0110
10+
"has no declaration" 404 — and a later POST succeeded. Nothing expired in
11+
between: the *listing* is what repaired it.
12+
13+
The lagging cache was the engine's `SchemaRegistry`. The runtime dispatch path
14+
(`resolveRouteActionDeclaration`) reads it as the live view of metadata, but
15+
`saveMetaItem` only wrote through it for `object` — every other overlay type
16+
reached the registry solely via the READ-side hydration in `getMetaItems`, so
17+
"has anyone listed this type yet?" silently decided whether a saved action
18+
could be invoked.
19+
20+
The fix is at the producer, per Prime Directive #12 — no retry, sleep, or
21+
fallback was added at the dispatch site:
22+
23+
- `saveMetaItem` (publish mode), draft publishing (`runPublishSideEffects`),
24+
and `rollbackMetaItem` now write EVERY overlay type through the registry via
25+
a shared `applyRegistryWriteThrough`, so an item that is listable is
26+
dispatchable in the same breath.
27+
- The write-through and the read-side hydration share one implementation
28+
(`hydrateOverlayIntoRegistry`), including the ADR-0010 §3.3 protection-envelope
29+
graft and the ADR-0048 package-scoped artifact lookup — a read and a write
30+
can no longer leave the registry in two different states for the same row.
31+
- Unchanged boundaries: drafts still never leak into the live registry, the
32+
`environmentId` scoping gate matches the read side, ADR-0110's 404 for a
33+
genuinely absent declaration stands, and DELETE ("reset to artifact default")
34+
still restores the packaged artifact — the overlay is a plain-key shadow, not
35+
an in-place overwrite.

packages/metadata-protocol/src/protocol.ts

Lines changed: 115 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2627,25 +2627,14 @@ export class ObjectStackProtocolImplementation implements
26272627

26282628
// Only hydrate the global registry for unscoped (control-plane)
26292629
// calls — scoped project entries must not leak process-wide.
2630-
// Graft the artifact's protection envelope onto the overlay body
2631-
// BEFORE registering: the plain-key entry written here shadows
2632-
// the packaged artifact on `registry.getItem`, and a bare
2633-
// overlay body would strip `_lock`/`_packageId`/`_provenance`
2634-
// from every registry-direct reader (ADR-0010 §3.3 — an overlay
2635-
// must never loosen a packaged lock). ADR-0048 (#1828) — scope
2636-
// the artifact lookup to the row's OWN package so a colliding
2637-
// overlay no longer grafts the first-registered package's
2638-
// provenance/lock onto another package's row.
2630+
// #4521 — this loop is no longer the ONLY way an overlay reaches
2631+
// the registry (the write writes through as well), so it is the
2632+
// shared {@link hydrateOverlayIntoRegistry} that both callers
2633+
// use: a read and a write that register differently would put
2634+
// the registry in two different states for the same row.
26392635
if (this.environmentId === undefined) {
26402636
for (const { data, packageId: recPkg } of overlays) {
2641-
if (data && typeof data === 'object' && 'name' in data) {
2642-
const artifact = this.lookupArtifactItem(request.type, (data as any).name, recPkg);
2643-
this.engine.registry.registerItem(
2644-
request.type,
2645-
mergeArtifactProtection(data, artifact),
2646-
'name' as any,
2647-
);
2648-
}
2637+
this.hydrateOverlayIntoRegistry(request.type, data, recPkg);
26492638
}
26502639
}
26512640
}
@@ -5970,6 +5959,89 @@ export class ObjectStackProtocolImplementation implements
59705959
}
59715960
}
59725961

5962+
/**
5963+
* Register ONE active overlay body into the engine's SchemaRegistry.
5964+
*
5965+
* The single implementation shared by the READ-side hydration
5966+
* (`getMetaItems`) and the WRITE-side write-through
5967+
* ({@link applyRegistryWriteThrough}) — #4521. Two copies of this rule
5968+
* would let a read and a write leave the registry in two different
5969+
* states for the same row, which is the class of bug the write-through
5970+
* exists to close.
5971+
*
5972+
* Graft the artifact's protection envelope onto the overlay body BEFORE
5973+
* registering: the plain-key entry written here shadows the packaged
5974+
* artifact on `registry.getItem`, and a bare overlay body would strip
5975+
* `_lock`/`_packageId`/`_provenance` from every registry-direct reader
5976+
* (ADR-0010 §3.3 — an overlay must never loosen a packaged lock).
5977+
* ADR-0048 (#1828) — scope the artifact lookup to the row's OWN package
5978+
* so a colliding overlay no longer grafts the first-registered package's
5979+
* provenance/lock onto another package's row.
5980+
*
5981+
* Returns whether anything was registered (bodies without a `name`, and
5982+
* registry doubles without `registerItem`, are no-ops).
5983+
*/
5984+
private hydrateOverlayIntoRegistry(type: string, data: unknown, packageId?: string | null): boolean {
5985+
if (!data || typeof data !== 'object' || !('name' in data)) return false;
5986+
const registry: any = (this.engine as any)?.registry;
5987+
if (!registry || typeof registry.registerItem !== 'function') return false;
5988+
const artifact = this.lookupArtifactItem(type, (data as any).name, packageId ?? undefined);
5989+
registry.registerItem(type, mergeArtifactProtection(data, artifact), 'name' as any);
5990+
return true;
5991+
}
5992+
5993+
/**
5994+
* [#4521] Write-through the SchemaRegistry after a mutation goes LIVE, so
5995+
* a just-saved item is dispatchable — not merely listable.
5996+
*
5997+
* `resolveRouteActionDeclaration` (and every other runtime consumer that
5998+
* reads `engine.registry` directly) treats the registry as the live view
5999+
* of metadata. Before this method the write only wrote through it for
6000+
* `object` ({@link applyObjectRegistryMutation} returns early otherwise);
6001+
* every other overlay type arrived in the registry solely via the
6002+
* READ-side hydration in `getMetaItems` / `loadMetaFromDb`. That made a
6003+
* *read* the thing that repaired the registry: a `PUT /meta/action/x`
6004+
* followed immediately by `POST /actions/<object>/x` answered the
6005+
* ADR-0110 "has no declaration" 404, and the very next listing call made
6006+
* the same POST succeed (#4432 F1, split out as #4521). Read-your-writes
6007+
* between the meta list and the dispatch path was decided by whether
6008+
* anyone had listed yet.
6009+
*
6010+
* The fix is at the producer, not the consumer: no retry, no sleep and no
6011+
* tolerance was added at the dispatch site, and ADR-0110's 404 for a
6012+
* genuinely absent declaration is untouched — an item nobody wrote still
6013+
* has nothing in the registry to find.
6014+
*
6015+
* Call ONLY after the write has landed and is live:
6016+
* • `saveMetaItem` repo path — post-`put()`, `mode === 'publish'` only
6017+
* (drafts are a staging buffer and must never leak into the runtime);
6018+
* • `runPublishSideEffects` — the draft→active promotion;
6019+
* • `rollbackMetaItem` — the restored body is the live one.
6020+
*
6021+
* The non-object branch carries the same `environmentId === undefined`
6022+
* gate the read-side hydration carries: a project-scoped row must not be
6023+
* registered into a registry that unscoped (control-plane) callers share.
6024+
* The write must not be more permissive about that than the read is.
6025+
*/
6026+
private applyRegistryWriteThrough(request: { type: string; name: string; item?: any; packageId?: string | null }): void {
6027+
if (request.type === 'object' || request.type === 'objects') {
6028+
this.applyObjectRegistryMutation(request);
6029+
return;
6030+
}
6031+
if (this.environmentId !== undefined) return;
6032+
try {
6033+
this.hydrateOverlayIntoRegistry(request.type, request.item, request.packageId ?? undefined);
6034+
} catch (err: any) {
6035+
// Best-effort, exactly like the object branch: the row is already
6036+
// persisted, so a registry hiccup must not fail the write that
6037+
// succeeded. It degrades to the pre-#4521 behaviour (the next
6038+
// listing hydrates it), never to a lost write.
6039+
console.warn(
6040+
`[Protocol] registry write-through failed for ${request.type}/${request.name}: ${err?.message ?? err}`,
6041+
);
6042+
}
6043+
}
6044+
59736045
/**
59746046
* Heal the in-memory registry after a metadata reset (overlay-row
59756047
* delete) on control-plane kernels. Two layers:
@@ -6493,12 +6565,21 @@ export class ObjectStackProtocolImplementation implements
64936565
...(request.packageId !== undefined ? { packageId: request.packageId } : {}),
64946566
});
64956567
// Persistence succeeded — NOW it's safe to mutate the
6496-
// in-memory object registry. If put() had thrown, the
6497-
// registry would still reflect the prior state. Drafts
6498-
// are NOT live: don't propagate them into the runtime
6499-
// object registry (would defeat the staging buffer).
6568+
// in-memory registry. If put() had thrown, the registry
6569+
// would still reflect the prior state. Drafts are NOT
6570+
// live: don't propagate them into the runtime registry
6571+
// (would defeat the staging buffer).
6572+
// #4521 — write through for EVERY overlay type, not just
6573+
// `object`: the runtime dispatch path reads this registry,
6574+
// so an item that is already listable must be dispatchable
6575+
// in the same breath. See {@link applyRegistryWriteThrough}.
65006576
if (mode === 'publish') {
6501-
this.applyObjectRegistryMutation(request);
6577+
this.applyRegistryWriteThrough({
6578+
type: singularTypeForRepo,
6579+
name: request.name,
6580+
item: request.item,
6581+
packageId: request.packageId ?? null,
6582+
});
65026583
await this.ensureObjectStorage(request.type, request.name);
65036584
}
65046585
// ADR-0010 — success audit (best-effort).
@@ -7147,12 +7228,15 @@ export class ObjectStackProtocolImplementation implements
71477228
projectionApplied?: MutationProjectionOutcome;
71487229
} = {};
71497230
// Drafts skipped the registry mutation; on publish we now refresh the
7150-
// runtime object registry so live behaviour catches up immediately
7151-
// (matches saveMetaItem's post-persistence registry update path).
7152-
this.applyObjectRegistryMutation({
7153-
type: args.requestType,
7231+
// runtime registry so live behaviour catches up immediately (matches
7232+
// saveMetaItem's post-persistence registry update path — #4521 makes
7233+
// that path cover every overlay type, so promoting a drafted action
7234+
// makes it dispatchable at once instead of at the next listing).
7235+
this.applyRegistryWriteThrough({
7236+
type: args.singularType,
71547237
name: args.name,
71557238
item: args.body,
7239+
packageId: args.packageId,
71567240
});
71577241
// Create the object's table now so it's CRUD-able without a restart.
71587242
await this.ensureObjectStorage(args.requestType, args.name);
@@ -8417,8 +8501,11 @@ export class ObjectStackProtocolImplementation implements
84178501
...(request.message ? { message: request.message } : {}),
84188502
intent,
84198503
});
8420-
this.applyObjectRegistryMutation({
8421-
type: request.type,
8504+
// #4521 — a rollback is a live write like any other: the restored
8505+
// body must be the one the runtime dispatches on immediately, not
8506+
// after someone lists the type.
8507+
this.applyRegistryWriteThrough({
8508+
type: singularType,
84228509
name: request.name,
84238510
item: result.item.body,
84248511
});

packages/objectql/src/protocol-meta.test.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -214,15 +214,30 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
214214
).rejects.toThrow('Item data is required');
215215
});
216216

217-
it('should NOT mutate the SchemaRegistry for non-object types (ADR-0005)', async () => {
218-
// ADR-0005: sys_metadata is the authoritative overlay store.
219-
// saveMetaItem must not pollute the artifact-loaded registry for
220-
// overlay-eligible types (view/dashboard/etc.) — getMetaItem reads
221-
// sys_metadata first, so the registry stays at the artifact value.
217+
it('writes the saved body through to the SchemaRegistry for non-object types (#4521)', async () => {
218+
// INVERTED from "should NOT mutate the SchemaRegistry for
219+
// non-object types (ADR-0005)". That assertion was written when
220+
// mutating the registry here meant OVERWRITING the artifact in
221+
// place, so `deleteMetaItem` ("reset to artifact default") would
222+
// have had nothing left to restore. That is no longer how the
223+
// registry stores the two: an artifact lives under the composite
224+
// `<packageId>:<name>` key, an overlay is a plain-key SHADOW, and
225+
// `restoreArtifactRegistryView` drops the shadow on delete. The
226+
// read side (`getMetaItems` hydration) has been writing that
227+
// shadow for a long time.
228+
//
229+
// Leaving the write alone therefore did not protect the artifact —
230+
// it only made a READ the thing that repaired the registry: a
231+
// just-saved overlay was listed but NOT dispatchable until someone
232+
// listed the type, because `resolveRouteActionDeclaration` reads
233+
// the registry (#4432 F1 → #4521). The rest of ADR-0005 is intact:
234+
// `sys_metadata` is still the authoritative store and `getMetaItem`
235+
// still consults it first.
222236
await protocol.saveMetaItem({ type: 'app', name: 'test_app', item: sampleApp });
223237

224238
const stored = registry.getItem('app', 'test_app');
225-
expect(stored).toBeUndefined();
239+
expect(stored).toBeDefined();
240+
expect((stored as any).name).toBe('test_app');
226241
});
227242

228243
it('should register `object` type items in SchemaRegistry (engine schema-sync needs it)', async () => {

0 commit comments

Comments
 (0)