@@ -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 } ) ;
0 commit comments