// Package registry resolves normalized NPC artifacts into immutable grounding // data for D&D extraction modules. package registry import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/registryresolver" ) const ( ReferenceSlot = "npc_registry" MaxBytes = 1048576 emptyPrompt = `{"npcs":[]}` ) // Registry is an immutable, validated NPC registry prepared for prompt // grounding. All accessors return defensive copies. type Registry struct { bound bool list dnd.NPCRegistry canonical []byte digest string projectionDigest string identityDigest string promptInput contracts.LLMInputMaterial lookupByKey map[string]int lookupByID map[string]int } // Resolver selects and memoizes immutable NPC registry views. type Resolver struct { resolver *registryresolver.Resolver[*Registry] } // NewResolver validates the optional construction-time NPC reference and // prepares the operation-time registry cache. A malformed static reference // therefore fails before any operation starts. func NewResolver(references contracts.ReferenceSet) (*Resolver, error) { resolver, err := registryresolver.New(registryResolverConfig(), references) if err != nil { return nil, err } return &Resolver{resolver: resolver}, nil } // Seeded returns the immutable construction-time registry. Its accessors are // defensive, so callers may safely use the returned view for static metadata. func (r *Resolver) Seeded() *Registry { if r == nil { return nil } return r.resolver.Seeded() } // Resolve returns the effective registry for one operation. An operation // without an NPC item uses the construction-time registry. A canonical item // matching that registry reuses it; other canonical registries are cached by // digest for concurrent chunk operations. func (r *Resolver) Resolve(references contracts.ReferenceSet) (*Registry, error) { if r == nil || r.resolver == nil { return Resolve(references) } return r.resolver.Resolve(references) } // Resolve prepares the optional NPC registry reference. An absent slot // produces the exact empty prompt input and no semantic registry identity. func Resolve(references contracts.ReferenceSet) (*Registry, error) { item, present, err := registryresolver.ResolveOptionalSingleItem(references, npcReferenceSpec()) if err != nil { return nil, err } if !present { return emptyRegistry(), nil } return loadRegistry(item.Content) } func registryResolverConfig() registryresolver.Config[*Registry] { return registryresolver.Config[*Registry]{ Reference: npcReferenceSpec(), Absent: func() (*Registry, error) { return emptyRegistry(), nil }, Load: loadRegistry, SemanticIdentity: func(registry *Registry) string { return registry.Digest() }, } } func npcReferenceSpec() registryresolver.ReferenceSpec { return registryresolver.ReferenceSpec{SlotName: ReferenceSlot, AcceptedMediaType: npccodec.MediaType, MaxBytes: MaxBytes} } func emptyRegistry() *Registry { content := []byte(emptyPrompt) projectionDigest := semanticDigest(content) return &Registry{ list: dnd.NPCRegistry{NPCs: []dnd.NPC{}}, canonical: append([]byte(nil), content...), projectionDigest: projectionDigest, identityDigest: projectionDigest, promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""), lookupByKey: map[string]int{}, lookupByID: map[string]int{}, } } func loadRegistry(referenceContent []byte) (*Registry, error) { codec := npccodec.New() value, err := codec.Decode(referenceContent) if err != nil { return nil, fmt.Errorf("decode NPC registry: invalid approved NPC JSON") } if issues := identity.ValidateList(value); len(issues) > 0 { return nil, fmt.Errorf("%s", formatIdentityIssues(issues)) } content, err := codec.Encode(value) if err != nil { return nil, fmt.Errorf("encode canonical NPC registry: approved NPC value could not be encoded") } list := cloneNPCRegistry(value) lookupByKey := make(map[string]int, len(list.NPCs)) lookupByID := make(map[string]int, len(list.NPCs)) for index, npc := range list.NPCs { lookupByKey[identity.ComparisonKey(npc.Name)] = index lookupByID[npc.ID] = index } digest := semanticDigest(content) projection, err := nameProjection(list) if err != nil { return nil, fmt.Errorf("encode NPC name projection: %w", err) } projectionDigest := semanticDigest(projection) identityProjection, err := identityProjection(list) if err != nil { return nil, fmt.Errorf("encode NPC identity projection: %w", err) } identityProjectionDigest := semanticDigest(identityProjection) return &Registry{ bound: true, list: list, canonical: append([]byte(nil), content...), digest: digest, projectionDigest: projectionDigest, identityDigest: identityProjectionDigest, promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""), lookupByKey: lookupByKey, lookupByID: lookupByID, }, nil } // Bound reports whether an NPC reference was supplied and validated. func (r *Registry) Bound() bool { return r != nil && r.bound } // NPCs returns a defensive copy of the validated NPC records. func (r *Registry) NPCs() []dnd.NPC { if r == nil { return nil } return cloneNPCs(r.list.NPCs) } // List returns a defensive copy of the validated NPC registry entries. func (r *Registry) List() dnd.NPCRegistry { if r == nil { return dnd.NPCRegistry{} } return cloneNPCRegistry(r.list) } // CanonicalBytes returns a defensive copy of the canonical durable JSON. func (r *Registry) CanonicalBytes() []byte { if r == nil { return nil } return append([]byte(nil), r.canonical...) } // Digest returns the semantic SHA-256 digest of the canonical JSON, or an // empty string when the registry is unbound. func (r *Registry) Digest() string { if r == nil { return "" } return r.digest } // ProjectionDigest returns the SHA-256 digest of the exact names-only prompt // projection, including for an unbound or empty registry. func (r *Registry) ProjectionDigest() string { if r == nil { return "" } return r.projectionDigest } // IdentityDigest returns the SHA-256 digest of the ordered ID/name identity // projection. It binds durable identities without exposing them to the model. func (r *Registry) IdentityDigest() string { if r == nil { return "" } return r.identityDigest } // Count returns the number of validated NPC records. func (r *Registry) Count() int { if r == nil { return 0 } return len(r.list.NPCs) } // PromptInput returns the names-only registry projection as a content-safe // prompt input. Durable IDs, evidence, and reference provenance are omitted. func (r *Registry) PromptInput() contracts.LLMInputMaterial { if r == nil { return contracts.LLMInputMaterial{} } return r.promptInput.Clone() } // Lookup returns the canonical NPC for an exact canonical-name match under the // NPC identity comparison policy. func (r *Registry) Lookup(value string) (dnd.NPC, bool) { if r == nil { return dnd.NPC{}, false } index, ok := r.lookupByKey[identity.ComparisonKey(value)] if !ok { return dnd.NPC{}, false } return cloneNPC(r.list.NPCs[index]), true } // LookupID returns the canonical NPC for an exact durable ID. func (r *Registry) LookupID(value string) (dnd.NPC, bool) { if r == nil { return dnd.NPC{}, false } index, ok := r.lookupByID[value] if !ok { return dnd.NPC{}, false } return cloneNPC(r.list.NPCs[index]), true } func semanticDigest(content []byte) string { sum := sha256.Sum256(content) return "sha256:" + hex.EncodeToString(sum[:]) } type projectedNPC struct { Name string `json:"name"` } type identityProjectedNPC struct { ID string `json:"id"` Name string `json:"name"` } type projectedNPCRegistry struct { NPCs []projectedNPC `json:"npcs"` } type identityProjectedNPCRegistry struct { NPCs []identityProjectedNPC `json:"npcs"` } func nameProjection(list dnd.NPCRegistry) ([]byte, error) { projection := projectedNPCRegistry{NPCs: make([]projectedNPC, len(list.NPCs))} for index, npc := range list.NPCs { projection.NPCs[index] = projectedNPC{Name: npc.Name} } return json.Marshal(projection) } func identityProjection(list dnd.NPCRegistry) ([]byte, error) { projection := identityProjectedNPCRegistry{NPCs: make([]identityProjectedNPC, len(list.NPCs))} for index, npc := range list.NPCs { projection.NPCs[index] = identityProjectedNPC{ID: npc.ID, Name: npc.Name} } return json.Marshal(projection) } func formatIdentityIssues(issues []identity.Issue) string { parts := make([]string, len(issues)) for index, issue := range issues { location := fmt.Sprintf("record %d", issue.RecordIndex) parts[index] = fmt.Sprintf("%s at %s", issue.Code, location) } return diagnostics.Aggregate("validate NPC registry identity", parts) } func cloneNPCRegistry(value dnd.NPCRegistry) dnd.NPCRegistry { return dnd.NPCRegistry{NPCs: cloneNPCs(value.NPCs)} } func cloneNPCs(values []dnd.NPC) []dnd.NPC { if values == nil { return nil } cloned := make([]dnd.NPC, len(values)) for index, value := range values { cloned[index] = cloneNPC(value) } return cloned } func cloneNPC(value dnd.NPC) dnd.NPC { value.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...) return value }