Skip to content

Commit 02b73b0

Browse files
committed
fix(metadata-protocol): the save door's carry-forward compares a row-less item with the code layer the read served (#20552)
Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent 004f70b commit 02b73b0

1 file changed

Lines changed: 44 additions & 2 deletions

File tree

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

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7299,7 +7299,7 @@ export class ObjectStackProtocolImplementation implements
72997299
* was carried, which is the silent deletion this method exists to prevent.
73007300
* The read is skipped entirely — no extra query, for any save — when the
73017301
* type has no registered redactor, which is every type but `datasource`
7302-
* today.
7302+
* (built in) and `flow` (registered by the automation plugin, #20552).
73037303
*/
73047304
private async carryForwardRedactedCredentials(args: {
73057305
type: string;
@@ -7320,11 +7320,53 @@ export class ObjectStackProtocolImplementation implements
73207320
packageId: args.packageId,
73217321
});
73227322
}
7323-
const body = stored?.body;
7323+
// [#20552] NO overlay row at either state: the body the read served is
7324+
// the CODE layer, so that is what the incoming body is compared with.
7325+
// Without this a code-authored item — an inbound flow whose secret is a
7326+
// literal in the app's source, the shipped reference shape — had its
7327+
// FIRST save persist an overlay row with the credential dropped, and
7328+
// an overlay wins every later merge.
7329+
const body = stored?.body ?? await this.readCodeLayerForCarryForward(args.type, args.ref.name, args.packageId);
73247330
if (!body) return args.item;
73257331
return carryForwardRedactedValues(args.type, args.item, body);
73267332
}
73277333

7334+
/**
7335+
* [#20552] The body a read served for an item with NO overlay row — the
7336+
* CODE layer, resolved in the order `getMetaItemLayered` resolves its `code`
7337+
* layer: the MetadataService item, else the loaded artifact's item, else the
7338+
* SchemaRegistry item (with the plural/singular retry). The artifact lookup
7339+
* comes before the plain registry key because an overlay hydrated into that
7340+
* key can shadow it (see {@link lookupArtifactItem}); here there is no
7341+
* overlay row, so a hydrated copy would be stale.
7342+
*
7343+
* Type-agnostic, like the carry-forward it feeds: a code-defined datasource
7344+
* gets the same first-save protection as a code-authored flow.
7345+
*
7346+
* ⛔ No `try`/`catch` — the carry-forward's own rule. A MetadataService read
7347+
* that throws fails the save, and one that reports itself degraded with
7348+
* nothing found anywhere fails it as the same 503 the read doors answer:
7349+
* the alternative is persisting a body whose credential this save could not
7350+
* confirm was carried.
7351+
*/
7352+
private async readCodeLayerForCarryForward(
7353+
type: string,
7354+
name: string,
7355+
packageId: string | null,
7356+
): Promise<unknown> {
7357+
const pkg = packageId ?? undefined;
7358+
const fromService = await this.readItemFromMetadataService(type, name, pkg);
7359+
if (fromService.data !== undefined && fromService.data !== null) return fromService.data;
7360+
let item: unknown = this.lookupArtifactItem(type, name, pkg) ?? this.engine.registry.getItem(type, name, pkg);
7361+
if (item === undefined) {
7362+
const alt = PLURAL_TO_SINGULAR[type] ?? SINGULAR_TO_PLURAL[type];
7363+
if (alt) item = this.engine.registry.getItem(alt, name, pkg);
7364+
}
7365+
if (item !== undefined && item !== null) return item;
7366+
if (fromService.degraded) this.throwMetadataServiceUnavailable(fromService.errors);
7367+
return undefined;
7368+
}
7369+
73287370
/**
73297371
* [#5840] Read ONE item from the `metadata` service, keeping the ADR-0110
73307372
* D3 verdict instead of flattening it into `undefined`.

0 commit comments

Comments
 (0)