Migrate NPC registry durable contract

This commit is contained in:
2026-08-05 18:25:04 +00:00
parent 916a32195b
commit 9653e06297
56 changed files with 256 additions and 237 deletions

View File

@@ -5,6 +5,7 @@ package identity
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
@@ -17,7 +18,7 @@ import (
const (
// Policy identifies the complete identity comparison and ID derivation
// policy. A future semantic change must use a new value.
Policy = "dnd.npcs.identity.v1"
Policy = "dnd.npc_registry.identity.v1"
// IdentityPolicy is an explicit alias for callers recording policy
// fingerprints.
IdentityPolicy = Policy
@@ -72,7 +73,11 @@ func DeriveID(name string) string {
if key == "" {
return ""
}
digest := sha256.Sum256([]byte(key))
identity, err := json.Marshal([]string{Policy, key})
if err != nil {
return ""
}
digest := sha256.Sum256(identity)
return idPrefix + hex.EncodeToString(digest[:])
}
@@ -98,7 +103,7 @@ func ValidID(value string) bool { return IsValidID(value) }
// ValidateRegistry checks all identity invariants without changing the input.
// It accepts the NPC slice used by typed pipeline artifacts. Use ValidateList
// when the enclosing NPCList is more convenient at the call site.
// when the enclosing NPCRegistry is more convenient at the call site.
func ValidateRegistry(npcs []dnd.NPC) []Issue {
issues := make([]Issue, 0)
canonicalOwners := make(map[string][]int)
@@ -136,7 +141,7 @@ func ValidateRegistry(npcs []dnd.NPC) []Issue {
}
// ValidateList validates the identity members of list.
func ValidateList(list dnd.NPCList) []Issue { return ValidateRegistry(list.NPCs) }
func ValidateList(list dnd.NPCRegistry) []Issue { return ValidateRegistry(list.NPCs) }
// Error makes an issue useful in simple callers while preserving its
// structured fields for aggregate diagnostics.

View File

@@ -1,6 +1,7 @@
package identity
import (
"reflect"
"strings"
"sync"
"testing"
@@ -40,9 +41,16 @@ func TestNormalizeDisplayOnlyChangesWhitespace(t *testing.T) {
func TestDeriveIDAndIDSyntax(t *testing.T) {
got := DeriveID(" Mira\u2003Thorn ")
const want = "npc:sha256:35ba5f679aee69e07ae3bd65c44278f29539d5dc9bb5225db1c0060555b23221"
if Policy != "dnd.npc_registry.identity.v1" || IdentityPolicy != Policy {
t.Fatalf("identity policy = %q/%q", Policy, IdentityPolicy)
}
if len(got) != len("npc:sha256:")+64 || !strings.HasPrefix(got, "npc:sha256:") || !IsValidID(got) {
t.Fatalf("DeriveID() = %q, want exact NPC ID syntax", got)
}
if got != want {
t.Fatalf("DeriveID() = %q, want SHA-256 of compact JSON [policy, comparison_name]", got)
}
if got != DeriveID("Mira Thorn") || got != IDFor("Mira Thorn") {
t.Fatal("DeriveID() is not deterministic across equivalent names")
}
@@ -80,7 +88,11 @@ func TestValidateRegistryReportsIdentityCollisionCategories(t *testing.T) {
{ID: validID, Name: "Mira Thorn"},
{ID: validID, Name: "Mira Thorn"},
}
before := append([]dnd.NPC(nil), npcs...)
issues := ValidateRegistry(npcs)
if !reflect.DeepEqual(npcs, before) {
t.Fatal("ValidateRegistry() mutated its input")
}
want := map[IssueCode]bool{
IssueDuplicateCanonical: false,
IssueDuplicateID: false,

View File

@@ -27,7 +27,7 @@ const (
// grounding. All accessors return defensive copies.
type Registry struct {
bound bool
list dnd.NPCList
list dnd.NPCRegistry
canonical []byte
digest string
projectionDigest string
@@ -105,7 +105,7 @@ func emptyRegistry() *Registry {
content := []byte(emptyPrompt)
projectionDigest := semanticDigest(content)
return &Registry{
list: dnd.NPCList{NPCs: []dnd.NPC{}},
list: dnd.NPCRegistry{NPCs: []dnd.NPC{}},
canonical: append([]byte(nil), content...),
projectionDigest: projectionDigest,
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
@@ -127,7 +127,7 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
return nil, fmt.Errorf("encode canonical NPC registry: approved NPC value could not be encoded")
}
list := cloneNPCList(value)
list := cloneNPCRegistry(value)
lookupByKey := make(map[string]int, len(list.NPCs))
for index, npc := range list.NPCs {
lookupByKey[identity.ComparisonKey(npc.Name)] = index
@@ -161,11 +161,11 @@ func (r *Registry) NPCs() []dnd.NPC {
}
// List returns a defensive copy of the validated NPC list.
func (r *Registry) List() dnd.NPCList {
func (r *Registry) List() dnd.NPCRegistry {
if r == nil {
return dnd.NPCList{}
return dnd.NPCRegistry{}
}
return cloneNPCList(r.list)
return cloneNPCRegistry(r.list)
}
// CanonicalBytes returns a defensive copy of the canonical durable JSON.
@@ -233,12 +233,12 @@ type projectedNPC struct {
Name string `json:"name"`
}
type projectedNPCList struct {
type projectedNPCRegistry struct {
NPCs []projectedNPC `json:"npcs"`
}
func nameProjection(list dnd.NPCList) ([]byte, error) {
projection := projectedNPCList{NPCs: make([]projectedNPC, len(list.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}
}
@@ -254,8 +254,8 @@ func formatIdentityIssues(issues []identity.Issue) string {
return diagnostics.Aggregate("validate NPC registry identity", parts)
}
func cloneNPCList(value dnd.NPCList) dnd.NPCList {
return dnd.NPCList{NPCs: cloneNPCs(value.NPCs)}
func cloneNPCRegistry(value dnd.NPCRegistry) dnd.NPCRegistry {
return dnd.NPCRegistry{NPCs: cloneNPCs(value.NPCs)}
}
func cloneNPCs(values []dnd.NPC) []dnd.NPC {

View File

@@ -164,14 +164,14 @@ func TestResolverHandlesConstructionAndOperationReferences(t *testing.T) {
}
}
func registryFixture() dnd.NPCList {
return dnd.NPCList{NPCs: []dnd.NPC{
func registryFixture() dnd.NPCRegistry {
return dnd.NPCRegistry{NPCs: []dnd.NPC{
{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}},
{ID: identity.DeriveID("Captain Vale"), Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}},
}}
}
func resolveList(t *testing.T, list dnd.NPCList) *Registry {
func resolveList(t *testing.T, list dnd.NPCRegistry) *Registry {
t.Helper()
registry, err := Resolve(listReferenceSet(t, list))
if err != nil {
@@ -180,7 +180,7 @@ func resolveList(t *testing.T, list dnd.NPCList) *Registry {
return registry
}
func listReferenceSet(t *testing.T, list dnd.NPCList) contracts.ReferenceSet {
func listReferenceSet(t *testing.T, list dnd.NPCRegistry) contracts.ReferenceSet {
t.Helper()
content, err := npccodec.New().Encode(list)
if err != nil {