From 04a99a1a22061a664b2e9f7c6bfe7e0fb94fb587 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 3 Sep 2026 22:46:21 -0400 Subject: [PATCH 1/3] Stabilize visual preview recipe identity --- docs/architecture/visual-previews.md | 4 +- docs/internal/storage-design.md | 5 ++ internal/processing/visual_preview.go | 38 +++++------ .../visual_preview_fingerprint_test.go | 67 +++++++++++++++++++ vault_test.go | 4 ++ 5 files changed, 97 insertions(+), 21 deletions(-) create mode 100644 internal/processing/visual_preview_fingerprint_test.go 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..8580cd29 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" @@ -27,28 +26,27 @@ import ( ) const ( - visualPreviewMaxEdgePixels = 4096 - visualPreviewMaxSourcePixels = 100_000_000 - visualPreviewJPEGQuality = 90 - visualPreviewMaxJPEGSegments = 1024 - visualPreviewMaxPNGChunks = 1024 - visualPreviewMaxWebPChunks = 1024 - visualPreviewMaxEXIFBytes = 1 << 20 - visualPreviewWebPAnimation = 1 << 1 - visualPreviewWebPEXIF = 1 << 3 - visualPreviewWebPICCProfile = 1 << 5 + visualPreviewMaxEdgePixels = 4096 + visualPreviewMaxSourcePixels = 100_000_000 + visualPreviewJPEGQuality = 90 + visualPreviewMaxJPEGSegments = 1024 + visualPreviewMaxPNGChunks = 1024 + visualPreviewMaxWebPChunks = 1024 + visualPreviewMaxEXIFBytes = 1 << 20 + visualPreviewWebPAnimation = 1 << 1 + visualPreviewWebPEXIF = 1 << 3 + visualPreviewWebPICCProfile = 1 << 5 + 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..168b4cc2 --- /dev/null +++ b/internal/processing/visual_preview_fingerprint_test.go @@ -0,0 +1,67 @@ +package processing + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "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) + assert.Contains(t, visualPreviewProcessorDescriptor, "max-edge=4096") + assert.Contains(t, visualPreviewProcessorDescriptor, "quality=90") +} 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) From 5311ae30dc919ba18c5e450ff7fb12af490334f7 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 3 Sep 2026 23:12:35 -0400 Subject: [PATCH 2/3] Couple preview identity tests to policy constants --- internal/processing/visual_preview.go | 21 ++++++++++--------- .../visual_preview_fingerprint_test.go | 7 +++++-- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/internal/processing/visual_preview.go b/internal/processing/visual_preview.go index 8580cd29..65d25298 100644 --- a/internal/processing/visual_preview.go +++ b/internal/processing/visual_preview.go @@ -26,16 +26,17 @@ import ( ) const ( - visualPreviewMaxEdgePixels = 4096 - visualPreviewMaxSourcePixels = 100_000_000 - visualPreviewJPEGQuality = 90 - visualPreviewMaxJPEGSegments = 1024 - visualPreviewMaxPNGChunks = 1024 - visualPreviewMaxWebPChunks = 1024 - visualPreviewMaxEXIFBytes = 1 << 20 - visualPreviewWebPAnimation = 1 << 1 - visualPreviewWebPEXIF = 1 << 3 - visualPreviewWebPICCProfile = 1 << 5 + visualPreviewMaxEdgePixels = 4096 + visualPreviewMaxSourcePixels = 100_000_000 + visualPreviewJPEGQuality = 90 + visualPreviewMaxJPEGSegments = 1024 + visualPreviewMaxPNGChunks = 1024 + visualPreviewMaxWebPChunks = 1024 + visualPreviewMaxEXIFBytes = 1 << 20 + 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" ) diff --git a/internal/processing/visual_preview_fingerprint_test.go b/internal/processing/visual_preview_fingerprint_test.go index 168b4cc2..36992404 100644 --- a/internal/processing/visual_preview_fingerprint_test.go +++ b/internal/processing/visual_preview_fingerprint_test.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/sha256" "encoding/hex" + "fmt" "image/color" "runtime/debug" "testing" @@ -62,6 +63,8 @@ func TestVisualPreviewDescriptorTracksLinkedDependenciesAndPolicy(t *testing.T) } } assert.Equal(t, "v0.44.0", xImageVersion) - assert.Contains(t, visualPreviewProcessorDescriptor, "max-edge=4096") - assert.Contains(t, visualPreviewProcessorDescriptor, "quality=90") + assert.Contains(t, visualPreviewProcessorDescriptor, + fmt.Sprintf("max-edge=%d", visualPreviewMaxEdgePixels)) + assert.Contains(t, visualPreviewProcessorDescriptor, + fmt.Sprintf("quality=%d", visualPreviewJPEGQuality)) } From 9f0c4e8b10ea39ae8af3ee631018471866ca9476 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 3 Sep 2026 23:28:43 -0400 Subject: [PATCH 3/3] Bound preview descriptor policy tokens --- internal/processing/visual_preview_fingerprint_test.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/processing/visual_preview_fingerprint_test.go b/internal/processing/visual_preview_fingerprint_test.go index 36992404..c0a8891e 100644 --- a/internal/processing/visual_preview_fingerprint_test.go +++ b/internal/processing/visual_preview_fingerprint_test.go @@ -63,8 +63,7 @@ func TestVisualPreviewDescriptorTracksLinkedDependenciesAndPolicy(t *testing.T) } } assert.Equal(t, "v0.44.0", xImageVersion) - assert.Contains(t, visualPreviewProcessorDescriptor, - fmt.Sprintf("max-edge=%d", visualPreviewMaxEdgePixels)) - assert.Contains(t, visualPreviewProcessorDescriptor, - fmt.Sprintf("quality=%d", visualPreviewJPEGQuality)) + descriptor := ":" + visualPreviewProcessorDescriptor + ":" + assert.Contains(t, descriptor, ":"+fmt.Sprintf("max-edge=%d", visualPreviewMaxEdgePixels)+":") + assert.Contains(t, descriptor, ":"+fmt.Sprintf("quality=%d", visualPreviewJPEGQuality)+":") }