Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/internal/storage-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ 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.

Persisted rendition-artifact roles are recognized by a store-owned Go registry,
which also supplies their stable order to backup derivative statistics. Retention
and provider-request authorization remain separate policy checks. The
`rendition_artifacts.role` column remains unconstrained text; unknown values
fail closed in Go.

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.
Expand Down
6 changes: 1 addition & 5 deletions internal/backupapp/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2630,9 +2630,5 @@ func TestDerivativeAuthorityCoversEveryProviderArtifactRole(t *testing.T) {
assert.Equal(t, want, class.Classification)
assert.Equal(t, int64(1), class.Count)
}
assert.Equal(t, []string{
"normalized_evidence", "sanitized_markdown",
"provider_image", "provider_markdown", "structured_evidence",
"provider_transcript", "lexical_projection",
}, classes)
assert.Equal(t, append(store.PersistedRenditionArtifactRoles(), "lexical_projection"), classes)
}
20 changes: 7 additions & 13 deletions internal/backupapp/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import (
"io"
"math"
"os"
"slices"
"strconv"

"go.kenn.io/kit/backup"

"go.kenn.io/docbank/document"
"go.kenn.io/docbank/internal/store"
docsqlite "go.kenn.io/docbank/sqlite"
)
Expand Down Expand Up @@ -55,6 +55,7 @@ type derivativeClassAccumulator struct {

func computeDerivativeAuthorityStats(ctx context.Context, q rowQuerier) (*DerivativeAuthorityStats, bool, error) {
classes := make(map[string]*derivativeClassAccumulator)
persistedRoles := store.PersistedRenditionArtifactRoles()
get := func(classification, class string) *derivativeClassAccumulator {
item := classes[class]
if item != nil {
Expand Down Expand Up @@ -86,6 +87,9 @@ func computeDerivativeAuthorityStats(ctx context.Context, q rowQuerier) (*Deriva
if err := rows.Scan(&role, &buildID, &artifactID, &blobHash, &size, &checksum); err != nil {
return fmt.Errorf("scanning derivative artifact: %w", err)
}
if !slices.Contains(persistedRoles, role) {
return errors.New("derivative artifact class is not catalog-authorized")
}
item := get("included", role)
if err := addDerivativeClassBytes(&item.LogicalBytes, size); err != nil {
return fmt.Errorf("derivative class %s: %w", role, err)
Expand Down Expand Up @@ -180,18 +184,8 @@ func computeDerivativeAuthorityStats(ctx context.Context, q rowQuerier) (*Deriva
if len(classes) == 0 {
return nil, false, nil
}
// Class names are the exact rendition_artifacts.role values the catalog
// persists (document.EvidenceArtifact* constants plus the two catalog-local
// roles); lexical_projection is synthesized above from segment rows.
ordered := []string{
"normalized_evidence", "sanitized_markdown",
string(document.EvidenceArtifactImage),
string(document.EvidenceArtifactMarkdown),
string(document.EvidenceArtifactStructured),
string(document.EvidenceArtifactTranscript),
"visual_preview",
"lexical_projection",
}
// Synthetic classes are local to backupapp and follow persisted roles.
ordered := append(store.PersistedRenditionArtifactRoles(), "visual_preview", "lexical_projection")
result := &DerivativeAuthorityStats{
Version: derivativeAuthorityVersion, ProviderDependent: []string{},
}
Expand Down
33 changes: 27 additions & 6 deletions internal/backupapp/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"go.kenn.io/docbank/document"
"go.kenn.io/docbank/internal/store"
docsqlite "go.kenn.io/docbank/sqlite"
)
Expand All @@ -29,11 +28,7 @@ func TestDerivativeAuthorityStatsAcceptEveryCatalogArtifactRole(t *testing.T) {
checksum TEXT, state TEXT
);`)
require.NoError(t, err)
roles := []string{
"normalized_evidence", "sanitized_markdown",
string(document.EvidenceArtifactImage), string(document.EvidenceArtifactMarkdown),
string(document.EvidenceArtifactStructured), string(document.EvidenceArtifactTranscript),
}
roles := store.PersistedRenditionArtifactRoles()
for index, role := range roles {
_, err = db.Exec(`INSERT INTO rendition_artifacts(
role,build_id,artifact_id,blob_hash,size,checksum
Expand All @@ -55,3 +50,29 @@ func TestDerivativeAuthorityStatsAcceptEveryCatalogArtifactRole(t *testing.T) {
}
assert.Equal(t, append(roles, "visual_preview"), classes)
}

func TestDerivativeAuthorityStatsRefuseUnregisteredRole(t *testing.T) {
for _, role := range []string{"provider_audio", "visual_preview", "lexical_projection"} {
t.Run(role, func(t *testing.T) {
db, err := store.DefaultSQLiteDriver().Open(filepath.Join(t.TempDir(), "unknown-role.db"),
docsqlite.OpenOptions{Access: docsqlite.Create, TransactionMode: docsqlite.Immediate})
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, db.Close()) })
_, err = db.Exec(`CREATE TABLE rendition_artifacts (
role TEXT, build_id TEXT, artifact_id TEXT, blob_hash TEXT, size INTEGER, checksum TEXT
); CREATE TABLE rendition_lexical_segments (
build_id TEXT, segment_id TEXT, checksum TEXT, text TEXT, segment_order INTEGER
); CREATE TABLE visual_preview_generations (
generation_id TEXT, output_blob_hash TEXT, output_size INTEGER,
checksum TEXT, state TEXT
); INSERT INTO rendition_artifacts(role,build_id,artifact_id,blob_hash,size,checksum)
VALUES(?,'build','artifact','blob',1,'checksum');`, role)
require.NoError(t, err)

stats, present, err := computeDerivativeAuthorityStats(t.Context(), db)
require.EqualError(t, err, "backupapp: derivative artifact class is not catalog-authorized")
assert.False(t, present)
assert.Nil(t, stats)
})
}
}
25 changes: 17 additions & 8 deletions internal/store/processing_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"reflect"
"slices"
"sort"
"strings"
"unicode/utf8"
Expand Down Expand Up @@ -46,6 +47,21 @@ const (
catalogArtifactSanitizedMarkdown = "sanitized_markdown"
)

var persistedRenditionArtifactRoles = []string{
catalogArtifactNormalizedEvidence,
catalogArtifactSanitizedMarkdown,
string(document.EvidenceArtifactImage),
string(document.EvidenceArtifactMarkdown),
string(document.EvidenceArtifactStructured),
string(document.EvidenceArtifactTranscript),
}

// PersistedRenditionArtifactRoles returns the recognized rendition roles in
// their stable backup order.
func PersistedRenditionArtifactRoles() []string {
return slices.Clone(persistedRenditionArtifactRoles)
}

type capturedArtifactPolicyV1 struct {
Roles []capturedArtifactRoleV1 `json:"roles"`
Version int `json:"version"`
Expand Down Expand Up @@ -1152,14 +1168,7 @@ func normalizeCapturedArtifactPolicyV1(raw jsontext.Value) (normalizedCapturedAr
}

func validCapturedArtifactRole(role string) bool {
switch role {
case catalogArtifactNormalizedEvidence, catalogArtifactSanitizedMarkdown,
string(document.EvidenceArtifactImage), string(document.EvidenceArtifactMarkdown),
string(document.EvidenceArtifactStructured), string(document.EvidenceArtifactTranscript):
return true
default:
return false
}
return slices.Contains(persistedRenditionArtifactRoles, role)
}

func validateCatalogLocatorV1(locator document.EvidenceLocatorV1) error {
Expand Down
18 changes: 18 additions & 0 deletions internal/store/processing_catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,24 @@ func TestRenditionCatalogSharesOneBuildAcrossVersionProfilesWithinVault(t *testi
assert.Equal(t, 2, attachmentCount)
}

func TestPersistedRenditionArtifactRoles(t *testing.T) {
want := []string{
catalogArtifactNormalizedEvidence, catalogArtifactSanitizedMarkdown,
string(document.EvidenceArtifactImage), string(document.EvidenceArtifactMarkdown),
string(document.EvidenceArtifactStructured), string(document.EvidenceArtifactTranscript),
}
assert.Equal(t, want, PersistedRenditionArtifactRoles())
for _, role := range want {
assert.True(t, validCapturedArtifactRole(role))
}
for _, role := range []string{"", "NORMALIZED_EVIDENCE", "provider_audio"} {
assert.False(t, validCapturedArtifactRole(role))
}
roles := PersistedRenditionArtifactRoles()
roles[0] = "mutated"
assert.Equal(t, want, PersistedRenditionArtifactRoles())
}

func TestRenditionCatalogRejectsCrossVaultAttachment(t *testing.T) {
s, versions := newRenditionCatalogFixture(t)
other := newTestStore(t)
Expand Down