diff --git a/docs/architecture/visual-previews.md b/docs/architecture/visual-previews.md index b9be0c44..664604e3 100644 --- a/docs/architecture/visual-previews.md +++ b/docs/architecture/visual-previews.md @@ -26,7 +26,9 @@ Every preview records the complete recipe that can affect its bytes: The canonical recipe bytes produce a stable fingerprint. Changing any of these choices creates a new immutable generation instead of rewriting an earlier -result. +result. The processor fingerprint is a maintained descriptor independent of +the Go runtime. Changing a byte-producing implementation or policy requires a +deliberate descriptor revision. ## Durable outcomes diff --git a/docs/internal/storage-design.md b/docs/internal/storage-design.md index 869b080c..e66fd2a0 100644 --- a/docs/internal/storage-design.md +++ b/docs/internal/storage-design.md @@ -22,6 +22,11 @@ These layers must not be collapsed. Node reachability is product policy; blob membership is docbank's physical authority boundary; offsets, reader caches, and repacking are storage mechanics. +Visual-preview generations use the canonical recipe fingerprint. The local +processor descriptor names byte-producing choices without treating the ambient +Go runtime version as identity; a descriptor revision is the deliberate +re-render signal. + Stable node IDs are document identity. Paths are derived from parent/name rows and can change or be reused. Blob hashes are content identity. Two nodes may share a blob without sharing document identity. diff --git a/internal/processing/visual_preview.go b/internal/processing/visual_preview.go index a5824122..65d25298 100644 --- a/internal/processing/visual_preview.go +++ b/internal/processing/visual_preview.go @@ -18,7 +18,6 @@ import ( "image/png" "io" "mime" - "runtime" xdraw "golang.org/x/image/draw" "golang.org/x/image/webp" @@ -37,18 +36,18 @@ const ( visualPreviewWebPAnimation = 1 << 1 visualPreviewWebPEXIF = 1 << 3 visualPreviewWebPICCProfile = 1 << 5 + // Bump the descriptor revision when any byte-producing choice changes. + visualPreviewProcessorDescriptor = "docbank-visual-preview:jpeg+png+gif-stdlib+webp+embedded-camera-raw+x-image-draw-v0.44.0:max-edge=4096:quality=90:alpha=white:v7" ) var visualPreviewRecipe = document.VisualPreviewRecipeV1{ - ContractVersion: document.VisualPreviewContractV1, - MaxEdgePixels: visualPreviewMaxEdgePixels, - OutputMediaType: "image/jpeg", - OrientationPolicy: "apply", - ColorPolicy: "srgb", - FramePolicy: "primary", - ProcessorFingerprint: fingerprintVisualPreviewProcessor( - "docbank-visual-preview:jpeg+png+gif-stdlib-" + runtime.Version() + - "+webp+embedded-camera-raw+x-image-draw-v0.44.0:max-edge=4096:quality=90:alpha=white:v6"), + ContractVersion: document.VisualPreviewContractV1, + MaxEdgePixels: visualPreviewMaxEdgePixels, + OutputMediaType: "image/jpeg", + OrientationPolicy: "apply", + ColorPolicy: "srgb", + FramePolicy: "primary", + ProcessorFingerprint: fingerprintVisualPreviewProcessor(visualPreviewProcessorDescriptor), } // VisualPreviewTarget identifies one exact immutable source to process. diff --git a/internal/processing/visual_preview_fingerprint_test.go b/internal/processing/visual_preview_fingerprint_test.go new file mode 100644 index 00000000..c0a8891e --- /dev/null +++ b/internal/processing/visual_preview_fingerprint_test.go @@ -0,0 +1,69 @@ +package processing + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "image/color" + "runtime/debug" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/docbank/document" + "go.kenn.io/docbank/document/media/mediatest" +) + +const ( + pinnedVisualPreviewProcessorFingerprint = "6391e667d07b0aab1622d2b4167ffe66496fe7d336beb603401d96cd8a11a641" + pinnedVisualPreviewRecipeFingerprint = "03f89b744cc013004c89b1babe6d779ee043652519c586453bcde761bd7a4b04" +) + +func TestVisualPreviewProcessorFingerprintIsPinned(t *testing.T) { + assert.Equal(t, pinnedVisualPreviewProcessorFingerprint, + CurrentVisualPreviewRecipe().ProcessorFingerprint) +} + +func TestVisualPreviewRecipeFingerprintIsPinned(t *testing.T) { + _, fingerprint, err := document.MarshalVisualPreviewRecipeV1(CurrentVisualPreviewRecipe()) + require.NoError(t, err) + assert.Equal(t, pinnedVisualPreviewRecipeFingerprint, fingerprint) +} + +func TestProducedVisualPreviewCarriesPinnedRecipe(t *testing.T) { + source := mediatest.JPEG(3, 2, color.White) + digest := sha256.Sum256(source) + + product, err := ProduceVisualPreview(t.Context(), bytes.NewReader(source), VisualPreviewTarget{ + SourceSHA256: hex.EncodeToString(digest[:]), + Size: int64(len(source)), + MediaType: "image/jpeg", + }) + require.NoError(t, err) + assert.Equal(t, document.VisualPreviewReady, product.Preview.State) + assert.Equal(t, CurrentVisualPreviewRecipe(), product.Preview.Recipe) + assert.Equal(t, pinnedVisualPreviewProcessorFingerprint, + product.Preview.Recipe.ProcessorFingerprint) + _, fingerprint, err := document.MarshalVisualPreviewRecipeV1(product.Preview.Recipe) + require.NoError(t, err) + assert.Equal(t, pinnedVisualPreviewRecipeFingerprint, fingerprint) +} + +func TestVisualPreviewDescriptorTracksLinkedDependenciesAndPolicy(t *testing.T) { + info, ok := debug.ReadBuildInfo() + require.True(t, ok) + + var xImageVersion string + for _, dependency := range info.Deps { + if dependency.Path == "golang.org/x/image" { + xImageVersion = dependency.Version + break + } + } + assert.Equal(t, "v0.44.0", xImageVersion) + descriptor := ":" + visualPreviewProcessorDescriptor + ":" + assert.Contains(t, descriptor, ":"+fmt.Sprintf("max-edge=%d", visualPreviewMaxEdgePixels)+":") + assert.Contains(t, descriptor, ":"+fmt.Sprintf("quality=%d", visualPreviewJPEGQuality)+":") +} diff --git a/vault_test.go b/vault_test.go index ea16e2a4..673c0f9d 100644 --- a/vault_test.go +++ b/vault_test.go @@ -381,6 +381,10 @@ func TestVaultEnsureVisualPreviewProducesBoundedJPEG(t *testing.T) { assert.Equal(t, 4096, preview.Output.Width) assert.Equal(t, 2, preview.Output.Height) assert.Equal(t, receipt.Version.ID, preview.Version.ID) + assert.Equal(t, "6391e667d07b0aab1622d2b4167ffe66496fe7d336beb603401d96cd8a11a641", + preview.Recipe.ProcessorFingerprint) + assert.Equal(t, "03f89b744cc013004c89b1babe6d779ee043652519c586453bcde761bd7a4b04", + preview.RecipeFingerprint) retry, err := vault.EnsureVisualPreview(t.Context(), receipt.Version.ID) require.NoError(t, err)