// Package registry resolves scene-description artifacts into immutable combat // eligibility data. package registry import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "mime" "sort" "strings" "sync" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" scenecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions" ) const ( ReferenceSlot = "scene_descriptions" MaxBytes = 1048576 emptyProjection = `{"scenes":[]}` ) // MatchState identifies how a chunk relates to the resolved scene records. type MatchState string const ( MatchExact MatchState = "exact" MatchMissing MatchState = "missing" MatchMismatched MatchState = "mismatched" ) // ChunkMatch describes whether a scene record exactly covers a chunk. Kind is // populated only for an exact match. type ChunkMatch struct { State MatchState Kind dnd.SceneKind } type sceneEligibility struct { sourceID string startUnitID int endUnitID int kind dnd.SceneKind } // Registry is an immutable, validated eligibility view. It retains no scene // prose, raw reference bytes, or reference provenance. type Registry struct { bound bool digest string scenesByID map[string]sceneEligibility } // Resolver holds an immutable construction-time view and memoizes immutable // operation-time views by raw reference identity and eligibility digest. type Resolver struct { seeded *Registry mu sync.Mutex cache map[string]*Registry rawCache map[string]*Registry } // NewResolver validates a materialized construction-time scene reference. An // empty slot is permitted because a generated reference is supplied only at // operation time. func NewResolver(references contracts.ReferenceSet) (*Resolver, error) { seeded, err := Resolve(constructionReferences(references)) if err != nil { return nil, err } return &Resolver{ seeded: seeded, cache: make(map[string]*Registry), rawCache: make(map[string]*Registry), }, nil } func constructionReferences(references contracts.ReferenceSet) contracts.ReferenceSet { slot, ok := references.Slots[ReferenceSlot] if !ok || len(slot.Items) > 0 { return references } cloned := contracts.ReferenceSet{Slots: make(map[string]contracts.ResolvedReferenceSlot, len(references.Slots))} for name, value := range references.Slots { cloned.Slots[name] = value } delete(cloned.Slots, ReferenceSlot) return cloned } // Seeded returns the construction-time eligibility view. func (r *Resolver) Seeded() *Registry { if r == nil { return nil } return r.seeded } // Resolve returns the generated operation-time view when the scene slot is // present, otherwise it returns the construction-time view. func (r *Resolver) Resolve(references contracts.ReferenceSet) (*Registry, error) { if r == nil { return Resolve(references) } if _, ok := references.Slots[ReferenceSlot]; !ok { return r.seeded, nil } slot := references.Slots[ReferenceSlot] rawKey := "" if len(slot.Items) == 1 { rawKey = rawReferenceKey(slot.Items[0]) } r.mu.Lock() defer r.mu.Unlock() if rawKey != "" { if cached, ok := r.rawCache[rawKey]; ok { return cached, nil } } resolved, err := Resolve(references) if err != nil { return nil, err } if sameEligibility(r.seeded, resolved) { if rawKey != "" { r.rawCache[rawKey] = r.seeded } return r.seeded, nil } if cached, ok := r.cache[resolved.EligibilityDigest()]; ok { if rawKey != "" { r.rawCache[rawKey] = cached } return cached, nil } r.cache[resolved.EligibilityDigest()] = resolved if rawKey != "" { r.rawCache[rawKey] = resolved } return resolved, nil } func rawReferenceKey(item contracts.ReferenceItem) string { digest := strings.ToLower(strings.TrimSpace(item.Digest)) if !validSHA256Digest(digest) { digest = semanticDigest(item.Content) } return strings.ToLower(strings.TrimSpace(item.MediaType)) + "\x00" + digest } func validSHA256Digest(value string) bool { if len(value) != len("sha256:")+sha256.Size*2 || !strings.HasPrefix(value, "sha256:") { return false } decoded, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:")) return err == nil && len(decoded) == sha256.Size } func sameEligibility(first, second *Registry) bool { if first == nil || second == nil { return first == second } return first.bound == second.bound && first.digest == second.digest } // Resolve validates one optional scene-description reference into an // eligibility-only view. An absent slot is represented by the canonical empty // projection so required generated bindings have a stable sentinel identity. func Resolve(references contracts.ReferenceSet) (*Registry, error) { slot, ok := references.Slots[ReferenceSlot] if !ok { return &Registry{ digest: semanticDigest([]byte(emptyProjection)), scenesByID: map[string]sceneEligibility{}, }, nil } if len(slot.Items) != 1 { return nil, fmt.Errorf("reference slot %q must contain exactly one item", ReferenceSlot) } item := slot.Items[0] mediaType, _, err := mime.ParseMediaType(item.MediaType) if err != nil { return nil, fmt.Errorf("reference slot %q item media type is invalid", ReferenceSlot) } if !strings.EqualFold(mediaType, scenecodec.MediaType) { return nil, fmt.Errorf("reference slot %q item media type must be %s", ReferenceSlot, scenecodec.MediaType) } if len(item.Content) > MaxBytes { return nil, fmt.Errorf("reference slot %q item is %d bytes, limit %d", ReferenceSlot, len(item.Content), MaxBytes) } value, err := scenecodec.New().Decode(item.Content) if err != nil { return nil, fmt.Errorf("decode scene eligibility: invalid approved scene JSON") } scenesByID := make(map[string]sceneEligibility, len(value.Scenes)) projection := make([]projectedScene, 0, len(value.Scenes)) for _, scene := range value.Scenes { if _, exists := scenesByID[scene.ID]; exists { return nil, fmt.Errorf("scene eligibility contains duplicate scene ID %q", scene.ID) } eligibility := sceneEligibility{ sourceID: scene.SourceRef.SourceID, startUnitID: scene.SourceRef.StartUnitID, endUnitID: scene.SourceRef.EndUnitID, kind: scene.Kind, } scenesByID[scene.ID] = eligibility projection = append(projection, projectedScene{ ID: scene.ID, SourceRef: source.SourceRef{SourceID: eligibility.sourceID, StartUnitID: eligibility.startUnitID, EndUnitID: eligibility.endUnitID}, Kind: eligibility.kind, }) } content, err := eligibilityProjection(projection) if err != nil { return nil, fmt.Errorf("encode scene eligibility projection: %w", err) } return &Registry{ bound: true, digest: semanticDigest(content), scenesByID: scenesByID, }, nil } // Bound reports whether an approved scene-description reference was supplied. func (r *Registry) Bound() bool { return r != nil && r.bound } // Count reports the number of scene records in the eligibility view. func (r *Registry) Count() int { if r == nil { return 0 } return len(r.scenesByID) } // EligibilityDigest returns the semantic SHA-256 digest of the canonical // eligibility projection, including the canonical unbound projection. func (r *Registry) EligibilityDigest() string { if r == nil { return "" } return r.digest } // Match classifies a chunk against its scene record. The kind is intentionally // unavailable unless the ID and complete source range match exactly. func (r *Registry) Match(chunk *source.Chunk) ChunkMatch { if r == nil || chunk == nil { return ChunkMatch{State: MatchMissing} } scene, ok := r.scenesByID[chunk.ID] if !ok { return ChunkMatch{State: MatchMissing} } if scene.sourceID != chunk.Ref.SourceID || scene.startUnitID != chunk.Ref.StartUnitID || scene.endUnitID != chunk.Ref.EndUnitID { return ChunkMatch{State: MatchMismatched} } return ChunkMatch{State: MatchExact, Kind: scene.kind} } type projectedScene struct { ID string `json:"id"` SourceRef source.SourceRef `json:"source_ref"` Kind dnd.SceneKind `json:"kind"` } type projectedSceneList struct { Scenes []projectedScene `json:"scenes"` } func eligibilityProjection(scenes []projectedScene) ([]byte, error) { sort.Slice(scenes, func(i, j int) bool { return scenes[i].ID < scenes[j].ID }) return json.Marshal(projectedSceneList{Scenes: scenes}) } func semanticDigest(content []byte) string { sum := sha256.Sum256(content) return "sha256:" + hex.EncodeToString(sum[:]) }