packer-pipeline-metadata-ownership

This commit is contained in:
bQUARKz 2026-04-10 05:58:27 +01:00
parent 11b24b0707
commit 76b5717cd8
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
6 changed files with 107 additions and 25 deletions

View File

@ -1,4 +1,5 @@
{"type":"meta","next_id":{"DSC":24,"AGD":26,"DEC":24,"PLN":47,"LSN":38,"CLSN":1}} {"type":"meta","next_id":{"DSC":25,"AGD":28,"DEC":25,"PLN":48,"LSN":40,"CLSN":1}}
{"type":"discussion","id":"DSC-0025","status":"done","ticket":"packer-pipeline-metadata-ownership","title":"Pipeline Metadata Ownership and Runtime Contract","created_at":"2026-04-09","updated_at":"2026-04-10","tags":["packer","metadata","runtime-contract","tooling","studio"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0039","file":"discussion/lessons/DSC-0025-packer-pipeline-metadata-ownership/LSN-0039-runtime-header-boundary-and-tooling-owned-pipeline-metadata.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0024","status":"done","ticket":"jacoco-reports-consolidation","title":"JaCoCo Reports Consolidation in Gradle","created_at":"2026-04-07","updated_at":"2026-04-07","tags":["infra","gradle","jacoco","coverage","jenkins"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0038","file":"discussion/lessons/DSC-0024-jacoco-reports-consolidation/LSN-0038-jacoco-reports-consolidation.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]} {"type":"discussion","id":"DSC-0024","status":"done","ticket":"jacoco-reports-consolidation","title":"JaCoCo Reports Consolidation in Gradle","created_at":"2026-04-07","updated_at":"2026-04-07","tags":["infra","gradle","jacoco","coverage","jenkins"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0038","file":"discussion/lessons/DSC-0024-jacoco-reports-consolidation/LSN-0038-jacoco-reports-consolidation.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}
{"type":"discussion","id":"DSC-0001","status":"done","ticket":"studio-docs-import","title":"Import docs/studio into discussion-framework artifacts","created_at":"2026-03-26","updated_at":"2026-03-26","tags":["studio","migration","discussion-framework","docs-import"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0001","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0001-assets-workspace-execution-wave-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0002","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0002-bank-composition-editor-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0003","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0003-mental-model-asset-mutations-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0004","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0004-mental-model-assets-workspace-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0005","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0005-mental-model-studio-events-and-components-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0006","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0006-mental-model-studio-shell-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0007","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0007-pack-wizard-shell-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0008","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0008-project-scoped-state-and-activity-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0016","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0016-studio-docs-import-pattern.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"}]} {"type":"discussion","id":"DSC-0001","status":"done","ticket":"studio-docs-import","title":"Import docs/studio into discussion-framework artifacts","created_at":"2026-03-26","updated_at":"2026-03-26","tags":["studio","migration","discussion-framework","docs-import"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0001","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0001-assets-workspace-execution-wave-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0002","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0002-bank-composition-editor-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0003","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0003-mental-model-asset-mutations-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0004","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0004-mental-model-assets-workspace-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0005","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0005-mental-model-studio-events-and-components-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0006","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0006-mental-model-studio-shell-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0007","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0007-pack-wizard-shell-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0008","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0008-project-scoped-state-and-activity-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0016","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0016-studio-docs-import-pattern.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"}]}
{"type":"discussion","id":"DSC-0002","status":"open","ticket":"palette-management-in-studio","title":"Palette Management in Studio","created_at":"2026-03-26","updated_at":"2026-03-26","tags":["studio","legacy-import","palette-management","tile-bank","packer-boundary"],"agendas":[{"id":"AGD-0002","file":"AGD-0002-palette-management-in-studio.md","status":"open","created_at":"2026-03-26","updated_at":"2026-03-26"}],"decisions":[],"plans":[],"lessons":[]} {"type":"discussion","id":"DSC-0002","status":"open","ticket":"palette-management-in-studio","title":"Palette Management in Studio","created_at":"2026-03-26","updated_at":"2026-03-26","tags":["studio","legacy-import","palette-management","tile-bank","packer-boundary"],"agendas":[{"id":"AGD-0002","file":"AGD-0002-palette-management-in-studio.md","status":"open","created_at":"2026-03-26","updated_at":"2026-03-26"}],"decisions":[],"plans":[],"lessons":[]}

View File

@ -0,0 +1,71 @@
---
id: LSN-0039
ticket: packer-pipeline-metadata-ownership
title: Runtime Header Boundary and Tooling-Owned Pipeline Metadata
created: 2026-04-10
tags: [packer, metadata, runtime-contract, tooling, codec, assets-pa]
---
## Context
The packer had drift between implementation and spec around `output.pipeline` ownership. The runtime-facing artifact `assets.pa` was documented as if pipeline metadata should materialize under `asset_table[].metadata.pipeline`, while the implementation already treated at least part of that surface as tooling-oriented and excluded palette payloads from the runtime header.
This created the wrong architectural pressure: Studio and third-party tools could start depending on `assets.pa` for authoring/build provenance, and runtime-facing specs could silently absorb build-time structures that do not belong in the execution contract.
The discussion resolved that split explicitly and implemented it end to end in specs, packer materialization, and tests.
## Key Decisions
### Tooling Metadata Must Not Leak Into the Runtime Header
**What:**
`output.pipeline` remains tooling-only metadata in `asset.json` and related sidecar outputs. The runtime-facing `assets.pa` header must not mirror `output.pipeline` by default. Runtime-owned metadata remains under explicit, normalized fields in `asset_table[].metadata`.
**Why:**
`assets.pa` is the authoritative runtime artifact. If it starts carrying build provenance, editor-oriented structures, or reverse-sync payloads merely because they exist in the authoring manifest, the runtime contract becomes larger, noisier, and harder to evolve safely.
**Trade-offs:**
Tools that want provenance cannot rely on one binary artifact alone. They must read `asset.json` and/or `build/asset_table_metadata.json`. That is a deliberate trade-off in favor of a cleaner runtime boundary.
### Promotion Must Be Explicit, Not a Bulk Mirror
**What:**
If pipeline-derived information is needed at runtime, it must be promoted into an explicit normative field under runtime-owned metadata. The packer must not reintroduce a generic mirrored `pipeline` object under another name.
**Why:**
Bulk mirroring hides product decisions inside implementation convenience. Explicit promotion forces each field to justify its presence in the runtime contract.
**Trade-offs:**
This adds small editorial and implementation work whenever a new runtime-facing derived field is introduced, but it keeps the contract intentional and testable.
### Codec Serialization Must Stay in `SCREAMING_SNAKE_CASE`
**What:**
Serialized asset entry `codec` values are locked to `SCREAMING_SNAKE_CASE`. The currently known `NONE` value remains serialized as `NONE`.
**Why:**
The implementation already had a stable manifest/runtime representation through `OutputCodecCatalog`. Recording that casing in spec prevents future drift when more codecs are added.
**Trade-offs:**
Future codec additions must preserve the established serialized style unless a new decision explicitly revises the contract.
## Patterns and Algorithms
- Use separate surfaces for separate consumers: `assets.pa` for runtime contract, `asset_table_metadata.json` for tooling-oriented provenance, and `asset.json` for authoring intent.
- Build runtime metadata from explicit normalized fields first, then derive tooling sidecars from a superset when needed.
- Keep the runtime header free of raw authoring/build objects even if the sidecar keeps them intact for Studio or inspection tools.
- Lock serialized enum casing in spec as soon as the first value ships, even if the current catalog is minimal.
## Pitfalls
- Do not treat “already present in the manifest” as sufficient reason to serialize a field into the runtime header.
- Do not let sidecar convenience become implicit runtime compatibility.
- Do not partially filter a tooling object and leave an empty placeholder in runtime metadata unless that placeholder has explicit runtime meaning.
- Do not leave enum casing as an implementation accident. If the manifest value matters, the spec must say so.
## Takeaways
- The runtime header is a contract surface, not a metadata dump.
- Tooling provenance belongs in `asset.json` and `build/asset_table_metadata.json`, not in `assets.pa`.
- Runtime-facing derived metadata must be promoted explicitly field by field.
- Stable serialized casing rules should be fixed in spec before the value set grows.

View File

@ -92,8 +92,9 @@ Rules:
- `output.format` defines the semantic/runtime format contract; - `output.format` defines the semantic/runtime format contract;
- `output.codec` defines storage/extraction behavior; - `output.codec` defines storage/extraction behavior;
- `output.codec` serialized values must use `SCREAMING_SNAKE_CASE`;
- `output.metadata` carries runtime-relevant format-specific detail; - `output.metadata` carries runtime-relevant format-specific detail;
- `output.pipeline` carries pipeline-injected metadata kept separate at authoring time; - `output.pipeline` carries tooling/build metadata kept separate at authoring time;
- codec must remain explicit and must not be hidden inside format naming. - codec must remain explicit and must not be hidden inside format naming.
### Explicit Index Collections ### Explicit Index Collections
@ -139,6 +140,8 @@ Rules:
- declaration-time metadata may come from multiple sources under the asset contract (for example, format metadata, codec-related metadata, and build/pipeline-derived declarations); - declaration-time metadata may come from multiple sources under the asset contract (for example, format metadata, codec-related metadata, and build/pipeline-derived declarations);
- `output.pipeline` may carry nested pipeline-derived metadata objects; - `output.pipeline` may carry nested pipeline-derived metadata objects;
- this segmentation exists for authoring clarity and does not define multiple runtime sinks; - this segmentation exists for authoring clarity and does not define multiple runtime sinks;
- `output.pipeline` is tooling-only and must not become part of the runtime-facing asset header by default;
- pipeline-derived values required at runtime must be promoted explicitly into normative runtime-owned metadata fields;
- runtime consumers must read effective metadata from the runtime asset entry metadata sink (`AssetEntry.metadata`); - runtime consumers must read effective metadata from the runtime asset entry metadata sink (`AssetEntry.metadata`);
- convergence/normalization behavior is normative in the build artifact specification. - convergence/normalization behavior is normative in the build artifact specification.

View File

@ -95,9 +95,15 @@ Baseline normalized metadata segmentation:
- `output.metadata` materializes at the metadata root; - `output.metadata` materializes at the metadata root;
- `output.codec_configuration` materializes under `metadata.codec`; - `output.codec_configuration` materializes under `metadata.codec`;
- `output.pipeline` materializes under `metadata.pipeline`; - `output.pipeline` remains tooling-only and must not materialize under `asset_table[].metadata` by default;
- pipeline-derived runtime-required values must be promoted explicitly into normative runtime-owned metadata fields;
- format-specific runtime-required fields may remain directly readable at the metadata root when the runtime consumer requires them there. - format-specific runtime-required fields may remain directly readable at the metadata root when the runtime consumer requires them there.
Asset entry codec serialization:
- `asset_table[].codec` MUST use `SCREAMING_SNAKE_CASE`;
- the current known `NONE` value serializes as `NONE`.
### Preload ### Preload
Preload is emitted deterministically from per-asset declaration. Preload is emitted deterministically from per-asset declaration.
@ -120,7 +126,7 @@ Rules:
- `build/asset_table.json` mirrors `header.asset_table` 1:1; - `build/asset_table.json` mirrors `header.asset_table` 1:1;
- `build/preload.json` mirrors `header.preload` 1:1; - `build/preload.json` mirrors `header.preload` 1:1;
- `build/asset_table_metadata.json` is tooling-only; - `build/asset_table_metadata.json` is tooling-only and may retain pipeline/build provenance that is intentionally excluded from the runtime header;
- richer tooling data must not be added to the 1:1 mirror files. - richer tooling data must not be added to the 1:1 mirror files.
## Alignment and Offsets ## Alignment and Offsets

View File

@ -878,7 +878,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
"size", packedAsset.payload().length, "size", packedAsset.payload().length,
"decoded_size", packedAsset.decodedSize(), "decoded_size", packedAsset.decodedSize(),
"codec", packedAsset.codec(), "codec", packedAsset.codec(),
"metadata", packedAsset.metadata()))); "metadata", packedAsset.runtimeMetadata())));
if (packedAsset.preloadEnabled()) { if (packedAsset.preloadEnabled()) {
final int slot = nextPreloadSlotByBankType.getOrDefault(packedAsset.bankType(), 0); final int slot = nextPreloadSlotByBankType.getOrDefault(packedAsset.bankType(), 0);
nextPreloadSlotByBankType.put(packedAsset.bankType(), slot + 1); nextPreloadSlotByBankType.put(packedAsset.bankType(), slot + 1);
@ -888,7 +888,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
} }
assetTableMetadata.add(new LinkedHashMap<>(Map.of( assetTableMetadata.add(new LinkedHashMap<>(Map.of(
"asset_id", packedAsset.assetId(), "asset_id", packedAsset.assetId(),
"metadata", packedAsset.metadata()))); "metadata", packedAsset.toolingMetadata())));
} }
final byte[] headerBytes = canonicalJsonBytes(Map.of( final byte[] headerBytes = canonicalJsonBytes(Map.of(
@ -960,20 +960,22 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
System.arraycopy(packedPixels, 0, payload, 0, packedPixels.length); System.arraycopy(packedPixels, 0, payload, 0, packedPixels.length);
System.arraycopy(paletteBytes, 0, payload, packedPixels.length, paletteBytes.length); System.arraycopy(paletteBytes, 0, payload, packedPixels.length, paletteBytes.length);
final LinkedHashMap<String, Object> metadata = new LinkedHashMap<>(); final LinkedHashMap<String, Object> runtimeMetadata = new LinkedHashMap<>();
metadata.put("tile_size", tileSize); runtimeMetadata.put("tile_size", tileSize);
metadata.put("width", width); runtimeMetadata.put("width", width);
metadata.put("height", height); runtimeMetadata.put("height", height);
metadata.put("palette_count", 64); runtimeMetadata.put("palette_count", 64);
metadata.put("palette_authored", countAuthoredTileBankPalettes(declaration)); runtimeMetadata.put("palette_authored", countAuthoredTileBankPalettes(declaration));
metadata.put("codec", Map.of()); runtimeMetadata.put("codec", Map.of());
metadata.put("pipeline", normalizeTileBankRuntimePipelineMetadata(declaration));
declaration.outputMetadata().forEach((key, value) -> { declaration.outputMetadata().forEach((key, value) -> {
if (!"tile_size".equals(key)) { if (!"tile_size".equals(key)) {
metadata.putIfAbsent(key, value); runtimeMetadata.putIfAbsent(key, value);
} }
}); });
final LinkedHashMap<String, Object> toolingMetadata = new LinkedHashMap<>(runtimeMetadata);
toolingMetadata.put("pipeline", normalizeToolingPipelineMetadata(declaration));
final PackerRegistryEntry registryEntry = runtimeAsset.registryEntry() final PackerRegistryEntry registryEntry = runtimeAsset.registryEntry()
.orElseThrow(() -> new IllegalStateException("Packed runtime asset must be registered")); .orElseThrow(() -> new IllegalStateException("Packed runtime asset must be registered"));
return new PackedAsset( return new PackedAsset(
@ -983,7 +985,8 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
"NONE", "NONE",
payload, payload,
width * height + 2048, width * height + 2048,
metadata, runtimeMetadata,
toolingMetadata,
declaration.preloadEnabled()); declaration.preloadEnabled());
} }
@ -1044,11 +1047,8 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
return palettesNode instanceof ArrayNode palettesArray ? palettesArray.size() : 0; return palettesNode instanceof ArrayNode palettesArray ? palettesArray.size() : 0;
} }
private Map<String, Object> normalizeTileBankRuntimePipelineMetadata(PackerAssetDeclaration declaration) { private Map<String, Object> normalizeToolingPipelineMetadata(PackerAssetDeclaration declaration) {
final LinkedHashMap<String, Object> pipeline = new LinkedHashMap<>( return new LinkedHashMap<>(PackerReadMessageMapper.normalizeMetadata(declaration.outputPipelineMetadata()));
PackerReadMessageMapper.normalizeMetadata(declaration.outputPipelineMetadata()));
pipeline.remove("palettes");
return pipeline;
} }
private byte[] buildPrelude(int headerLength) { private byte[] buildPrelude(int headerLength) {
@ -1111,7 +1111,8 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
String codec, String codec,
byte[] payload, byte[] payload,
int decodedSize, int decodedSize,
Map<String, Object> metadata, Map<String, Object> runtimeMetadata,
Map<String, Object> toolingMetadata,
boolean preloadEnabled) { boolean preloadEnabled) {
} }

View File

@ -337,8 +337,7 @@ final class FileSystemPackerWorkspaceServiceTest {
assertEquals(256, assetTable.get(0).path("metadata").path("height").asInt()); assertEquals(256, assetTable.get(0).path("metadata").path("height").asInt());
assertEquals(64, assetTable.get(0).path("metadata").path("palette_count").asInt()); assertEquals(64, assetTable.get(0).path("metadata").path("palette_count").asInt());
assertEquals(1, assetTable.get(0).path("metadata").path("palette_authored").asInt()); assertEquals(1, assetTable.get(0).path("metadata").path("palette_authored").asInt());
assertTrue(assetTable.get(0).path("metadata").path("pipeline").isObject()); assertTrue(assetTable.get(0).path("metadata").path("pipeline").isMissingNode());
assertTrue(assetTable.get(0).path("metadata").path("pipeline").path("palettes").isMissingNode());
final var preload = MAPPER.readTree(projectRoot.resolve("build/preload.json").toFile()); final var preload = MAPPER.readTree(projectRoot.resolve("build/preload.json").toFile());
assertEquals(1, preload.size()); assertEquals(1, preload.size());
@ -348,7 +347,8 @@ final class FileSystemPackerWorkspaceServiceTest {
final var assetTableMetadata = MAPPER.readTree(projectRoot.resolve("build/asset_table_metadata.json").toFile()); final var assetTableMetadata = MAPPER.readTree(projectRoot.resolve("build/asset_table_metadata.json").toFile());
assertEquals(1, assetTableMetadata.size()); assertEquals(1, assetTableMetadata.size());
assertEquals(1, assetTableMetadata.get(0).path("metadata").path("palette_authored").asInt()); assertEquals(1, assetTableMetadata.get(0).path("metadata").path("palette_authored").asInt());
assertTrue(assetTableMetadata.get(0).path("metadata").path("pipeline").path("palettes").isMissingNode()); assertEquals(1, assetTableMetadata.get(0).path("metadata").path("pipeline").path("palettes").size());
assertEquals(0, assetTableMetadata.get(0).path("metadata").path("pipeline").path("palettes").get(0).path("index").asInt());
} }
@Test @Test