diff --git a/internal/kernel/requirementsourcecodec/diagnostic_path.go b/internal/kernel/requirementsourcecodec/diagnostic_path.go index 5828eed..332ae7f 100644 --- a/internal/kernel/requirementsourcecodec/diagnostic_path.go +++ b/internal/kernel/requirementsourcecodec/diagnostic_path.go @@ -43,7 +43,7 @@ func resolveRequirementPath(wire document, segments []string) diagnosticPath { if len(tail) == 0 { return sameDiagnosticPath(base) } - if isMetadataField(tail[0]) { + if _, known := metadataFieldPresence(memberValue.Fields, tail[0]); known { base = metadataOwnerPath(wire, groupValue, memberValue, groupIndex, memberIndex, tail[0]) base = joinPointer(base, tail[0]) tail = tail[1:] @@ -56,44 +56,41 @@ func resolveRequirementPath(wire document, segments []string) diagnosticPath { func metadataOwnerPath(wire document, groupValue group, memberValue member, groupIndex int, memberIndex int, field string) string { memberBase := pointer("groups", groupIndex, "members", memberIndex, "fields") - if metadataFieldPresent(memberValue.Fields, field) || groupValue.ProfileID == "" { + if present, _ := metadataFieldPresence(memberValue.Fields, field); present || groupValue.ProfileID == "" { return memberBase } for profileIndex, profileValue := range wire.Profiles { - if profileValue.ProfileID == groupValue.ProfileID && metadataFieldPresent(profileValue.Fields, field) { + if present, _ := metadataFieldPresence(profileValue.Fields, field); profileValue.ProfileID == groupValue.ProfileID && present { return pointer("profiles", profileIndex, "fields") } } return memberBase } -func metadataFieldPresent(fields metadataFields, field string) bool { +func metadataFieldPresence(fields metadataFields, field string) (present bool, known bool) { switch field { + case "nonClaims": + return fields.NonClaims != nil, true + case "externalNonClaimRefs": + return fields.ExternalNonClaimRefs != nil, true + case "proofBindingRefs": + return fields.ProofBindingRefs != nil, true case "ownerId": - return fields.OwnerID != nil + return fields.OwnerID != nil, true case "claimLevel": - return fields.ClaimLevel != nil + return fields.ClaimLevel != nil, true case "riskClass": - return fields.RiskClass != nil + return fields.RiskClass != nil, true case "nonClaimRefs": - return fields.NonClaimRefs != nil + return fields.NonClaimRefs != nil, true case "lifecycle": - return fields.Lifecycle != nil + return fields.Lifecycle != nil, true case "deferral": - return fields.Deferral != nil + return fields.Deferral != nil, true case "updatePolicy": - return fields.UpdatePolicy != nil + return fields.UpdatePolicy != nil, true default: - return false - } -} - -func isMetadataField(value string) bool { - switch value { - case "ownerId", "claimLevel", "riskClass", "nonClaimRefs", "lifecycle", "deferral", "updatePolicy": - return true - default: - return false + return false, false } } @@ -186,8 +183,11 @@ func modelPathSegments(path string) []string { return segments } end += offset - if _, err := strconv.Atoi(path[offset+1 : end]); err == nil { - segments = append(segments, path[offset+1:end]) + component := path[offset+1 : end] + if identifier, err := strconv.Unquote(component); err == nil { + segments = append(segments, identifier) + } else if _, err := strconv.Atoi(component); err == nil { + segments = append(segments, component) } offset = end + 1 default: diff --git a/internal/kernel/requirementsourcecodec/diagnostics_test.go b/internal/kernel/requirementsourcecodec/diagnostics_test.go index b113fa4..7f3805b 100644 --- a/internal/kernel/requirementsourcecodec/diagnostics_test.go +++ b/internal/kernel/requirementsourcecodec/diagnostics_test.go @@ -2,10 +2,163 @@ package requirementsourcecodec import ( "bytes" + "encoding/json" + "fmt" + "slices" "strings" "testing" ) +func TestEffectiveNonClaimDuplicateSelectsParticipatingReferenceOwner(t *testing.T) { + for _, owner := range []string{"source", "member", "profile"} { + for _, mixed := range []bool{false, true} { + for _, reordered := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/mixed=%t/reordered=%t", owner, mixed, reordered), func(t *testing.T) { + payload, path, refs := nonClaimDuplicatePayload(t, owner, mixed, reordered, false) + _, err := Parse(payload) + assertDiagnostic(t, err, "duplicate_effective_nonclaim", path) + expected, encodeErr := json.Marshal(refs) + if encodeErr != nil { + t.Fatal(encodeErr) + } + span := err.(*Error).Diagnostic().Span + if !bytes.Equal(payload[span.Start:span.End], expected) { + t.Fatal("diagnostic did not select the exact participating reference array") + } + distinct, _, _ := nonClaimDuplicatePayload(t, owner, mixed, reordered, true) + if _, err := Parse(distinct); err != nil { + t.Fatalf("distinct statement control was rejected: %v", err) + } + }) + } + } + } +} + +func nonClaimDuplicatePayload(t *testing.T, owner string, mixed, reordered, distinct bool) ([]byte, string, []any) { + t.Helper() + const statement = "Named boundary references do not execute witnesses." + const different = "A separate boundary statement remains independent." + var refs []any + groupIndex, memberIndex := 1, 0 + if reordered { + groupIndex, memberIndex = 0, 1 + } + path := fmt.Sprintf("/groups/%d/members/%d/fields/nonClaimRefs", groupIndex, memberIndex) + payload := mutateRoot(t, mustPayload(t), func(root map[string]any) { + groups := root["groups"].([]any) + members := groups[1].(map[string]any)["members"].([]any) + fields := members[0].(map[string]any)["fields"].(map[string]any) + refOwner, directOwner := fields, fields + refKey, directKey := "nonClaimRefs", "nonClaims" + if owner == "source" { + refOwner, directOwner = root, root + refKey, directKey = "sourceNonClaimRefs", "sourceNonClaims" + path = "/sourceNonClaimRefs" + } else if owner == "profile" { + refOwner = root["profiles"].([]any)[0].(map[string]any)["fields"].(map[string]any) + refOwner[refKey] = fields[refKey] + for _, member := range members { + delete(member.(map[string]any)["fields"].(map[string]any), refKey) + } + path = "/profiles/0/fields/nonClaimRefs" + } + refs = append(refOwner[refKey].([]any), "NCL-DUP-A") + definitions := root["nonClaimDefinitions"].([]any) + definitions = append(definitions, map[string]any{"nonClaimId": "NCL-DUP-A", "statement": statement}) + directOwner[directKey] = []any{} + if mixed { + text := statement + if distinct { + text = different + } + directOwner[directKey] = []any{text} + } else { + text := statement + if distinct { + text = different + } + definitions = append(definitions, map[string]any{"nonClaimId": "NCL-DUP-B", "statement": text}) + refs = append(refs, "NCL-DUP-B") + } + if reordered { + slices.Reverse(definitions) + slices.Reverse(refs) + groups[0], groups[1] = groups[1], groups[0] + members[0], members[1] = members[1], members[0] + } + root["nonClaimDefinitions"] = definitions + refOwner[refKey] = refs + }) + return payload, path, refs +} + +func TestDottedRequirementDiagnosticsPreserveIdentityAndLexicalOwner(t *testing.T) { + for _, profileOwned := range []bool{false, true} { + for _, reordered := range []bool{false, true} { + t.Run(fmt.Sprintf("profile=%t/reordered=%t", profileOwned, reordered), func(t *testing.T) { + groupIndex, memberIndex := 1, 0 + if reordered { + groupIndex, memberIndex = 0, 1 + } + payload := mutateRoot(t, mustPayload(t), func(root map[string]any) { + groups := root["groups"].([]any) + group := groups[1].(map[string]any) + members := group["members"].([]any) + first := members[0].(map[string]any) + first["requirementId"] = "REQ-AA.alpha" + members[1].(map[string]any)["requirementId"] = "REQ-AA.alpha.proofBindingRefs" + first["fields"].(map[string]any)["proofBindingRefs"] = []any{} + if profileOwned { + profile := root["profiles"].([]any)[0].(map[string]any) + profile["fields"].(map[string]any)["proofBindingRefs"] = []any{} + for _, member := range members { + delete(member.(map[string]any)["fields"].(map[string]any), "proofBindingRefs") + } + } + if reordered { + groups[0], groups[1] = groups[1], groups[0] + members[0], members[1] = members[1], members[0] + } + }) + path := fmt.Sprintf("/groups/%d/members/%d/fields/proofBindingRefs", groupIndex, memberIndex) + if profileOwned { + path = "/profiles/0/fields/proofBindingRefs" + } + _, err := Parse(payload) + assertDiagnostic(t, err, "missing_proof_binding", path) + span := err.(*Error).Diagnostic().Span + if !bytes.Equal(payload[span.Start:span.End], []byte("[]")) { + t.Fatal("diagnostic did not select the complete owner value") + } + if strings.Contains(err.Error(), "REQ-AA") { + t.Fatal("diagnostic echoed the dynamic identifier") + } + }) + } + } +} + +func TestDottedDefinitionDiagnosticsResolveArrayEntry(t *testing.T) { + for _, item := range []struct { + root string + code string + value map[string]any + }{ + {"profiles", "vacuous_profile", map[string]any{"profileId": "RPROF-AA.alpha", "fields": map[string]any{"ownerId": "proofkit.extra"}}}, + {"nonClaimDefinitions", "unreferenced_definition", map[string]any{"nonClaimId": "NCL-AA.alpha", "statement": "This statement has an independent owner."}}, + {"vocabulary", "unreferenced_vocabulary", map[string]any{"termId": "TERM-AA.alpha", "kind": "subject", "label": "sample", "definition": "An unused sample term."}}, + } { + t.Run(item.root, func(t *testing.T) { + payload := mutateRoot(t, mustPayload(t), func(root map[string]any) { + root[item.root] = append([]any{item.value}, root[item.root].([]any)...) + }) + _, err := Parse(payload) + assertDiagnostic(t, err, item.code, "/"+item.root+"/0") + }) + } +} + func TestInvalidUTF8UsesByteOnlyCoordinates(t *testing.T) { payload := []byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'} _, err := Parse(payload) diff --git a/internal/kernel/requirementsourcecodec/document.go b/internal/kernel/requirementsourcecodec/document.go index 375d928..aa60e7f 100644 --- a/internal/kernel/requirementsourcecodec/document.go +++ b/internal/kernel/requirementsourcecodec/document.go @@ -12,6 +12,7 @@ type document struct { Kind string `json:"kind"` SourceID string `json:"sourceId"` SpecPackagePath string `json:"specPackagePath"` + SourceNonClaims []string `json:"sourceNonClaims"` SourceNonClaimRefs []string `json:"sourceNonClaimRefs"` NonClaimDefinitions []nonClaimDefinition `json:"nonClaimDefinitions"` Vocabulary []vocabularyTerm `json:"vocabulary"` @@ -74,13 +75,16 @@ type member struct { } type metadataFields struct { - OwnerID *string `json:"ownerId,omitempty"` - ClaimLevel *string `json:"claimLevel,omitempty"` - RiskClass *string `json:"riskClass,omitempty"` - NonClaimRefs *[]string `json:"nonClaimRefs,omitempty"` - Lifecycle *lifecycle `json:"lifecycle,omitempty"` - Deferral json.RawMessage `json:"deferral,omitempty"` - UpdatePolicy *updatePolicy `json:"updatePolicy,omitempty"` + NonClaims *[]string `json:"nonClaims,omitempty"` + ExternalNonClaimRefs *[]string `json:"externalNonClaimRefs,omitempty"` + ProofBindingRefs *[]string `json:"proofBindingRefs,omitempty"` + OwnerID *string `json:"ownerId,omitempty"` + ClaimLevel *string `json:"claimLevel,omitempty"` + RiskClass *string `json:"riskClass,omitempty"` + NonClaimRefs *[]string `json:"nonClaimRefs,omitempty"` + Lifecycle *lifecycle `json:"lifecycle,omitempty"` + Deferral json.RawMessage `json:"deferral,omitempty"` + UpdatePolicy *updatePolicy `json:"updatePolicy,omitempty"` } type lifecycle struct { @@ -178,12 +182,22 @@ func draftFromDocument(value document) (requirementsourcemodel.Draft, error) { } return requirementsourcemodel.Draft{ SourceID: value.SourceID, SpecPackagePath: value.SpecPackagePath, SourceNonClaimRefs: cloneStrings(value.SourceNonClaimRefs), + SourceNonClaims: cloneStrings(value.SourceNonClaims), NonClaimDefinitions: definitions, Vocabulary: vocabulary, Derivations: derivations, Profiles: profiles, Groups: groups, Scenarios: scenarios, }, nil } func modelMetadata(value metadataFields) (requirementsourcemodel.MetadataFields, error) { result := requirementsourcemodel.MetadataFields{} + if value.NonClaims != nil { + result.NonClaims = requirementsourcemodel.Own(cloneStrings(*value.NonClaims)) + } + if value.ExternalNonClaimRefs != nil { + result.ExternalNonClaimRefs = requirementsourcemodel.Own(cloneStrings(*value.ExternalNonClaimRefs)) + } + if value.ProofBindingRefs != nil { + result.ProofBindingRefs = requirementsourcemodel.Own(cloneStrings(*value.ProofBindingRefs)) + } if value.OwnerID != nil { result.OwnerID = requirementsourcemodel.Own(*value.OwnerID) } @@ -222,6 +236,7 @@ func documentFromModel(model requirementsourcemodel.Model) (document, error) { references := model.References() value := document{ SchemaVersion: SchemaVersion, Kind: DocumentKind, SourceID: atomic.SourceID, SpecPackagePath: atomic.SpecPackagePath, + SourceNonClaims: nonNilStrings(atomic.SourceNonClaims), SourceNonClaimRefs: nonNilStrings(atomic.SourceNonClaimRefs), NonClaimDefinitions: make([]nonClaimDefinition, len(atomic.NonClaimDefinitions)), Vocabulary: make([]vocabularyTerm, len(atomic.Vocabulary)), Derivations: make([]derivation, len(references.Derivations)), Profiles: make([]profile, len(layout.Profiles)), Groups: make([]group, len(layout.Groups)), Scenarios: make([]scenario, len(atomic.Scenarios)), @@ -269,6 +284,18 @@ func documentFromModel(model requirementsourcemodel.Model) (document, error) { func wireMetadata(value requirementsourcemodel.MetadataFields) (metadataFields, error) { result := metadataFields{} + if value.NonClaims.Present { + item := nonNilStrings(value.NonClaims.Value) + result.NonClaims = &item + } + if value.ExternalNonClaimRefs.Present { + item := nonNilStrings(value.ExternalNonClaimRefs.Value) + result.ExternalNonClaimRefs = &item + } + if value.ProofBindingRefs.Present { + item := nonNilStrings(value.ProofBindingRefs.Value) + result.ProofBindingRefs = &item + } if value.OwnerID.Present { item := value.OwnerID.Value result.OwnerID = &item diff --git a/internal/kernel/requirementsourcecodec/field_manifest_test.go b/internal/kernel/requirementsourcecodec/field_manifest_test.go index d16475d..780418b 100644 --- a/internal/kernel/requirementsourcecodec/field_manifest_test.go +++ b/internal/kernel/requirementsourcecodec/field_manifest_test.go @@ -208,6 +208,7 @@ func manifestLimitOwner(recordID string, fieldName string) string { "derivation.requirementIds": limitCollectionItems, "derivation.nonClaimRefs": limitCollectionItems, "document.sourceNonClaimRefs": limitCollectionItems, + "document.sourceNonClaims": limitCollectionItems, "document.nonClaimDefinitions": limitDefinitions, "document.vocabulary": limitTerms, "document.derivations": limitDerivations, @@ -220,6 +221,9 @@ func manifestLimitOwner(recordID string, fieldName string) string { "lifecycle.replacementRequirementIds": limitCollectionItems, "lifecycle.evidenceRefs": limitCollectionItems, "metadataFields.nonClaimRefs": limitCollectionItems, + "metadataFields.nonClaims": limitCollectionItems, + "metadataFields.externalNonClaimRefs": limitCollectionItems, + "metadataFields.proofBindingRefs": limitCollectionItems, "scenario.requirementIds": limitCollectionItems, "scenario.parameters": limitCollectionItems, "scenario.preconditions": limitCollectionItems, diff --git a/internal/kernel/requirementsourcecodec/fixture_test.go b/internal/kernel/requirementsourcecodec/fixture_test.go index 1c54584..28232f5 100644 --- a/internal/kernel/requirementsourcecodec/fixture_test.go +++ b/internal/kernel/requirementsourcecodec/fixture_test.go @@ -23,18 +23,24 @@ func testDraft() requirementsourcemodel.Draft { }, } memberFields := requirementsourcemodel.MetadataFields{ - NonClaimRefs: requirementsourcemodel.Own([]string{"NCL-CODEC-002"}), - Lifecycle: requirementsourcemodel.Own(active), - Deferral: requirementsourcemodel.Own[*requirementsourcemodel.Deferral](nil), + NonClaims: requirementsourcemodel.Own([]string{"Requirement admission does not execute native witnesses."}), + ExternalNonClaimRefs: requirementsourcemodel.Own([]string{"proofkit.nonclaim.native"}), + ProofBindingRefs: requirementsourcemodel.Own([]string{"proofkit/requirement-bindings.json"}), + NonClaimRefs: requirementsourcemodel.Own([]string{"NCL-CODEC-002"}), + Lifecycle: requirementsourcemodel.Own(active), + Deferral: requirementsourcemodel.Own[*requirementsourcemodel.Deferral](nil), } completeFields := func(claim requirementsourcemodel.ClaimLevel, lifecycle requirementsourcemodel.Lifecycle, deferral *requirementsourcemodel.Deferral) requirementsourcemodel.MetadataFields { return requirementsourcemodel.MetadataFields{ - OwnerID: requirementsourcemodel.Own("proofkit.codec"), - ClaimLevel: requirementsourcemodel.Own(claim), - RiskClass: requirementsourcemodel.Own(requirementsourcemodel.RiskMedium), - NonClaimRefs: requirementsourcemodel.Own([]string{"NCL-CODEC-002"}), - Lifecycle: requirementsourcemodel.Own(lifecycle), - Deferral: requirementsourcemodel.Own(deferral), + NonClaims: requirementsourcemodel.Own([]string{"Requirement admission does not execute native witnesses."}), + ExternalNonClaimRefs: requirementsourcemodel.Own([]string{"proofkit.nonclaim.native"}), + ProofBindingRefs: requirementsourcemodel.Own([]string{"proofkit/requirement-bindings.json"}), + OwnerID: requirementsourcemodel.Own("proofkit.codec"), + ClaimLevel: requirementsourcemodel.Own(claim), + RiskClass: requirementsourcemodel.Own(requirementsourcemodel.RiskMedium), + NonClaimRefs: requirementsourcemodel.Own([]string{"NCL-CODEC-002"}), + Lifecycle: requirementsourcemodel.Own(lifecycle), + Deferral: requirementsourcemodel.Own(deferral), UpdatePolicy: requirementsourcemodel.Own(requirementsourcemodel.UpdatePolicy{ ReviewOwnerID: "proofkit.codec", RequiresImpactDeclaration: true, @@ -53,6 +59,7 @@ func testDraft() requirementsourcemodel.Draft { return requirementsourcemodel.Draft{ SourceID: "proofkit.codec.source", SpecPackagePath: "docs/specs/proofkit-codec", + SourceNonClaims: []string{"Source admission does not execute native witnesses."}, SourceNonClaimRefs: []string{"NCL-CODEC-001"}, NonClaimDefinitions: []requirementsourcemodel.NonClaimDefinition{ {NonClaimID: "NCL-CODEC-001", Statement: "The codec does not prove implementation correctness."}, diff --git a/internal/kernel/requirementsourcecodec/limit_manifest_test.go b/internal/kernel/requirementsourcecodec/limit_manifest_test.go index 132a37b..8516e27 100644 --- a/internal/kernel/requirementsourcecodec/limit_manifest_test.go +++ b/internal/kernel/requirementsourcecodec/limit_manifest_test.go @@ -78,6 +78,52 @@ func TestCanonicalByteBoundCoversWorstAdmittedEscapeExpansion(t *testing.T) { } func assertMaximalEscapedTextRoundTrip(t *testing.T, scalar string) { + t.Helper() + assertMaximalTextRoundTrip(t, scalar, "text_budget_exceeded", func(draft *requirementsourcemodel.Draft, value string) { + draft.NonClaimDefinitions[0].Statement = value + }) +} + +func TestBoundaryMetadataMaximalTextPreservesPairedCodecLimits(t *testing.T) { + t.Run("source", func(t *testing.T) { + assertMaximalTextRoundTrip(t, "\x00", "text_budget_exceeded", func(draft *requirementsourcemodel.Draft, value string) { + draft.SourceNonClaims = []string{value} + }) + }) + for _, item := range []struct { + name string + scalar string + limitCode string + set func(*requirementsourcemodel.MetadataFields, requirementsourcemodel.Field[[]string]) + }{ + {"nonClaims", "\x00", "text_budget_exceeded", func(fields *requirementsourcemodel.MetadataFields, value requirementsourcemodel.Field[[]string]) { + fields.NonClaims = value + }}, + {"externalNonClaimRefs", "a", "invalid_id", func(fields *requirementsourcemodel.MetadataFields, value requirementsourcemodel.Field[[]string]) { + fields.ExternalNonClaimRefs = value + }}, + {"proofBindingRefs", "a", "text_budget_exceeded", func(fields *requirementsourcemodel.MetadataFields, value requirementsourcemodel.Field[[]string]) { + fields.ProofBindingRefs = value + }}, + } { + for _, owner := range []string{"member", "profile"} { + t.Run(item.name+"/"+owner, func(t *testing.T) { + assertMaximalTextRoundTrip(t, item.scalar, item.limitCode, func(draft *requirementsourcemodel.Draft, value string) { + fields := &draft.Groups[0].Members[0].Fields + if owner == "profile" { + for index := range draft.Groups[0].Members { + item.set(&draft.Groups[0].Members[index].Fields, requirementsourcemodel.Field[[]string]{}) + } + fields = &draft.Profiles[0].Fields + } + item.set(fields, requirementsourcemodel.Own([]string{value})) + }) + }) + } + } +} + +func assertMaximalTextRoundTrip(t *testing.T, scalar string, limitCode string, set func(*requirementsourcemodel.Draft, string)) { t.Helper() limits := compactTestModelLimits() low := 0 @@ -85,17 +131,17 @@ func assertMaximalEscapedTextRoundTrip(t *testing.T, scalar string) { for low+1 < high { middle := low + (high-low)/2 draft := testDraft() - draft.NonClaimDefinitions[0].Statement = "X" + strings.Repeat(scalar, middle) + "Y" + set(&draft, "X"+strings.Repeat(scalar, middle)+"Y") if _, err := requirementsourcemodel.NormalizeWithLimits(draft, limits); err == nil { low = middle - } else if requirementsourcemodel.ErrorCode(err) == "text_budget_exceeded" { + } else if requirementsourcemodel.ErrorCode(err) == limitCode { high = middle } else { t.Fatalf("boundary search encountered an unrelated rejection: %v", err) } } draft := testDraft() - draft.NonClaimDefinitions[0].Statement = "X" + strings.Repeat(scalar, low) + "Y" + set(&draft, "X"+strings.Repeat(scalar, low)+"Y") model, err := requirementsourcemodel.NormalizeWithLimits(draft, limits) if err != nil { t.Fatalf("maximum admitted escape fixture error = %v", err) @@ -108,8 +154,15 @@ func assertMaximalEscapedTextRoundTrip(t *testing.T, scalar string) { if int64(len(payload)) > codecLimits.MaxOutputBytes { t.Fatalf("canonical bytes = %d, bound = %d", len(payload), codecLimits.MaxOutputBytes) } - escaped := []byte(fmt.Sprintf(`\u%04x`, []rune(scalar)[0])) - if low == 0 || bytes.Count(payload, escaped) != low { + escaped := []byte(strings.Repeat(scalar, low)) + if scalar != "a" { + unit := fmt.Sprintf(`\u%04x`, []rune(scalar)[0]) + if bytes.Count(payload, []byte(unit)) != low { + t.Fatal("admitted control scalar count changed during formatting") + } + escaped = []byte(strings.Repeat(unit, low)) + } + if low == 0 || bytes.Count(payload, escaped) != 1 { t.Fatal("maximal control text was not escaped exactly") } parsed, err := ParseWithLimits(payload, codecLimits, limits) @@ -117,8 +170,8 @@ func assertMaximalEscapedTextRoundTrip(t *testing.T, scalar string) { t.Fatal("maximal admitted text did not round trip under paired limits") } over := testDraft() - over.NonClaimDefinitions[0].Statement = "X" + strings.Repeat(scalar, high) + "Y" - if _, err := requirementsourcemodel.NormalizeWithLimits(over, limits); requirementsourcemodel.ErrorCode(err) != "text_budget_exceeded" { + set(&over, "X"+strings.Repeat(scalar, high)+"Y") + if _, err := requirementsourcemodel.NormalizeWithLimits(over, limits); requirementsourcemodel.ErrorCode(err) != limitCode { t.Fatalf("limit-plus-one model error = %v", err) } } diff --git a/internal/kernel/requirementsourcecodec/limits.go b/internal/kernel/requirementsourcecodec/limits.go index a51dba4..a3d814a 100644 --- a/internal/kernel/requirementsourcecodec/limits.go +++ b/internal/kernel/requirementsourcecodec/limits.go @@ -9,7 +9,7 @@ import ( const ( defaultMaxNesting = 64 - minimumJSONNesting = 7 + minimumJSONNesting = 9 canonicalByteBaseOverhead = 4096 lexicalTokenBaseOverhead = 1024 lexicalTokenItemCoefficient = 32 diff --git a/internal/kernel/requirementsourcecodec/limits_test.go b/internal/kernel/requirementsourcecodec/limits_test.go index cdd7596..d3ce1c8 100644 --- a/internal/kernel/requirementsourcecodec/limits_test.go +++ b/internal/kernel/requirementsourcecodec/limits_test.go @@ -2,12 +2,67 @@ package requirementsourcecodec import ( "bytes" + "encoding/json" + "fmt" "strings" "testing" "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" ) +func TestAcceptedNestingLimitsCoverCompleteCanonicalOutput(t *testing.T) { + model := mustModel(t) + payload := mustPayload(t) + var wire any + if err := json.Unmarshal(payload, &wire); err != nil { + t.Fatal(err) + } + depth := fullJSONValueDepth(wire) + // The fixture includes scalar leaves in member lifecycle and deferral arrays. + if depth != 9 { + t.Fatalf("complete fixture depth = %d, want 9", depth) + } + if minimumJSONNesting != depth { + t.Fatalf("complete wire depth = %d, minimum = %d", depth, minimumJSONNesting) + } + for nesting := 1; nesting <= defaultMaxNesting; nesting++ { + t.Run(fmt.Sprintf("depth=%d", nesting), func(t *testing.T) { + limits := DefaultLimits() + limits.MaxNesting = nesting + encoded, formatErr := FormatWithLimits(model, limits, requirementsourcemodel.DefaultLimits()) + if nesting < depth { + _, parseErr := ParseWithLimits(payload, limits, requirementsourcemodel.DefaultLimits()) + if formatErr == nil || parseErr == nil || ErrorCode(formatErr) != "" || ErrorCode(parseErr) != "" { + t.Fatalf("under-covering configuration: format=%v parse=%v", formatErr, parseErr) + } + return + } + if formatErr != nil { + t.Fatalf("valid nesting configuration: %v", formatErr) + } + parsed, err := ParseWithLimits(encoded, limits, requirementsourcemodel.DefaultLimits()) + if err != nil || !projectionsEqual(model, parsed.Model) { + t.Fatalf("canonical output failed whole-pair admission: %v", err) + } + }) + } +} + +func fullJSONValueDepth(value any) int { + childDepth := 0 + switch value := value.(type) { + case map[string]any: + for _, child := range value { + childDepth = max(childDepth, fullJSONValueDepth(child)) + } + case []any: + for _, child := range value { + childDepth = max(childDepth, fullJSONValueDepth(child)) + } + } + return childDepth + 1 +} + func TestRawByteBoundaryIsExactAndDominatesUTF8(t *testing.T) { modelLimits := compactTestModelLimits() codecLimits := pairedCodecLimits(t, modelLimits) diff --git a/internal/kernel/requirementsourcecodec/metadata_parity_test.go b/internal/kernel/requirementsourcecodec/metadata_parity_test.go new file mode 100644 index 0000000..cce2d3d --- /dev/null +++ b/internal/kernel/requirementsourcecodec/metadata_parity_test.go @@ -0,0 +1,139 @@ +package requirementsourcecodec + +import ( + "bytes" + "encoding/json" + "reflect" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +func TestBoundaryMetadataWireRoundTripPreservesExactOwnerValues(t *testing.T) { + draft := testDraft() + draft.SourceNonClaims = []string{"Source boundary keeps\nmultiple lines."} + fields := &draft.Groups[0].Members[0].Fields + fields.NonClaims = requirementsourcemodel.Own([]string{"Direct boundary keeps\ttabs."}) + fields.ExternalNonClaimRefs = requirementsourcemodel.Own([]string{"NCL-EXTERNAL", "external.native"}) + fields.ProofBindingRefs = requirementsourcemodel.Own([]string{"proofkit/alpha.json", "proofkit/beta.json"}) + model, err := requirementsourcemodel.Normalize(draft) + if err != nil { + t.Fatal(err) + } + payload, err := Format(model) + if err != nil { + t.Fatal(err) + } + parsed, err := Parse(payload) + if err != nil || !projectionsEqual(model, parsed.Model) { + t.Fatalf("metadata round trip failed: %v", err) + } + first := parsed.Model.Atomic().Requirements[0] + for _, check := range []struct{ got, want []string }{ + {parsed.Model.Atomic().SourceNonClaims, draft.SourceNonClaims}, + {first.NonClaims, fields.NonClaims.Value}, + {first.ExternalNonClaimRefs, fields.ExternalNonClaimRefs.Value}, + {first.ProofBindingRefs, fields.ProofBindingRefs.Value}, + } { + if !reflect.DeepEqual(check.got, check.want) { + t.Fatal("a boundary field changed exact independently authored values") + } + } + for path, expected := range map[string]string{ + "/sourceNonClaims/0": draft.SourceNonClaims[0], + "/groups/1/members/0/fields/nonClaims/0": fields.NonClaims.Value[0], + "/groups/1/members/0/fields/externalNonClaimRefs/0": fields.ExternalNonClaimRefs.Value[0], + "/groups/1/members/0/fields/proofBindingRefs/0": fields.ProofBindingRefs.Value[0], + } { + location, ok := parsed.SourceMap.Location(path) + if !ok { + t.Fatalf("missing metadata source-map path %s", path) + } + var actual string + if err := json.Unmarshal(payload[location.ValueSpan.Start:location.ValueSpan.End], &actual); err != nil || actual != expected { + t.Fatalf("source map replay for %s: %v", path, err) + } + } +} + +func TestBoundaryMetadataWireRejectsIncompleteOrInvalidFields(t *testing.T) { + for _, field := range []string{"nonClaims", "externalNonClaimRefs", "proofBindingRefs"} { + t.Run(field, func(t *testing.T) { + for _, invalid := range []struct { + value any + code string + tail string + }{ + {nil, "invalid_null", ""}, + {true, "invalid_type", ""}, + {"not an array", "invalid_type", ""}, + {[]any{true}, "invalid_type", "/0"}, + } { + payload := mutateRoot(t, mustPayload(t), func(root map[string]any) { + group := root["groups"].([]any)[1].(map[string]any) + member := group["members"].([]any)[0].(map[string]any) + member["fields"].(map[string]any)[field] = invalid.value + }) + _, err := Parse(payload) + assertDiagnostic(t, err, invalid.code, "/groups/1/members/0/fields/"+field+invalid.tail) + } + payload := mutateRoot(t, mustPayload(t), func(root map[string]any) { + group := root["groups"].([]any)[1].(map[string]any) + member := group["members"].([]any)[0].(map[string]any) + delete(member["fields"].(map[string]any), field) + }) + _, err := Parse(payload) + assertDiagnostic(t, err, "metadata_partition_violation", "/groups/1/members/0/fields/"+field) + }) + } + missing := mutateRoot(t, mustPayload(t), func(root map[string]any) { delete(root, "sourceNonClaims") }) + _, err := Parse(missing) + assertDiagnostic(t, err, "missing_field", "/sourceNonClaims") +} + +func TestBoundaryMetadataDiagnosticsResolveLexicalOwner(t *testing.T) { + for _, item := range []struct { + field string + value []string + code string + tail string + }{ + {"nonClaims", []string{"A direct boundary remains independent.", "A direct boundary remains independent."}, "duplicate_value", ""}, + {"externalNonClaimRefs", []string{"not an identifier"}, "invalid_id", "/0"}, + {"proofBindingRefs", []string{}, "missing_proof_binding", ""}, + } { + for _, owner := range []string{"member", "profile"} { + t.Run(item.field+"/"+owner, func(t *testing.T) { + path := "/groups/1/members/0/fields/" + item.field + if owner == "profile" { + path = "/profiles/0/fields/" + item.field + } + payload := mutateRoot(t, mustPayload(t), func(root map[string]any) { + group := root["groups"].([]any)[1].(map[string]any) + members := group["members"].([]any) + fields := members[0].(map[string]any)["fields"].(map[string]any) + if owner == "profile" { + profile := root["profiles"].([]any)[0].(map[string]any) + profile["fields"].(map[string]any)[item.field] = fields[item.field] + for _, member := range members { + delete(member.(map[string]any)["fields"].(map[string]any), item.field) + } + fields = profile["fields"].(map[string]any) + } + fields[item.field] = item.value + }) + _, err := Parse(payload) + assertDiagnostic(t, err, item.code, path+item.tail) + span := err.(*Error).Diagnostic().Span + var expected any = item.value + if item.tail == "/0" { + expected = item.value[0] + } + encoded, encodeErr := json.Marshal(expected) + if encodeErr != nil || !bytes.Equal(payload[span.Start:span.End], encoded) { + t.Fatal("diagnostic did not resolve the exact lexical metadata owner value") + } + }) + } + } +} diff --git a/internal/kernel/requirementsourcecodec/path_identity_test.go b/internal/kernel/requirementsourcecodec/path_identity_test.go new file mode 100644 index 0000000..cdc6b5e --- /dev/null +++ b/internal/kernel/requirementsourcecodec/path_identity_test.go @@ -0,0 +1,58 @@ +package requirementsourcecodec + +import ( + "strings" + "testing" + + model "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +func TestModelPathAdmissionIsClosedUnderWireEncoding(t *testing.T) { + for _, role := range []struct { + name string + set func(*model.Draft, string) + }{ + {"source", func(d *model.Draft, s string) { d.SpecPackagePath = s }}, + {"derivation", func(d *model.Draft, s string) { d.Derivations[0].SourceRef.Path = s }}, + {"member binding", func(d *model.Draft, s string) { + d.Groups[0].Members[0].Fields.ProofBindingRefs = model.Own([]string{s}) + }}, + {"profile binding", func(d *model.Draft, s string) { + d.Profiles[0].Fields.ProofBindingRefs = model.Own([]string{s}) + for index := range d.Groups[0].Members { + d.Groups[0].Members[index].Fields.ProofBindingRefs = model.Field[[]string]{} + } + }}, + {"lifecycle evidence", func(d *model.Draft, s string) { + d.Groups[2].Members[0].Fields.Lifecycle.Value.EvidenceRefs = []string{s} + }}, + {"deferral evidence", func(d *model.Draft, s string) { + d.Groups[1].Members[0].Fields.Deferral.Value.EvidenceRefs = []string{s} + }}, + } { + t.Run(role.name, func(t *testing.T) { + for _, path := range []string{"proofkit/\xff.json", "proofkit/\u03bb.json"} { + draft := testDraft() + role.set(&draft, path) + admitted, err := model.Normalize(draft) + if strings.Contains(path, "\xff") { + if model.ErrorCode(err) != "invalid_path" || strings.Contains(err.Error(), path) { + t.Fatalf("malformed UTF-8 path was not rejected safely: code=%q", model.ErrorCode(err)) + } + continue + } + if err != nil { + t.Fatalf("valid Unicode path rejected: %v", err) + } + payload, err := Format(admitted) + if err != nil { + t.Fatal(err) + } + reparsed, err := Parse(payload) + if err != nil || !projectionsEqual(admitted, reparsed.Model) { + t.Fatalf("path round trip changed source identity: %v", err) + } + } + }) + } +} diff --git a/internal/kernel/requirementsourcecodec/shape.go b/internal/kernel/requirementsourcecodec/shape.go index a9c5dc6..d0137f1 100644 --- a/internal/kernel/requirementsourcecodec/shape.go +++ b/internal/kernel/requirementsourcecodec/shape.go @@ -69,6 +69,7 @@ func documentShape(limits requirementsourcemodel.Limits) *shape { metadataValue := object(map[string]shapeField{ "ownerId": {shape: stringValue}, "claimLevel": {shape: stringValue}, "riskClass": {shape: stringValue}, "nonClaimRefs": {shape: stringsValue}, "lifecycle": {shape: lifecycleValue}, "deferral": {shape: deferralValue}, "updatePolicy": {shape: updatePolicyValue}, + "nonClaims": {shape: stringsValue}, "externalNonClaimRefs": {shape: stringsValue}, "proofBindingRefs": {shape: stringsValue}, }) nonClaimValue := object(requiredFields(map[string]*shape{"nonClaimId": stringValue, "statement": stringValue})) termValue := object(requiredFields(map[string]*shape{"termId": stringValue, "kind": stringValue, "label": stringValue, "definition": stringValue})) @@ -103,6 +104,7 @@ func documentShape(limits requirementsourcemodel.Limits) *shape { "sourceId": stringValue, "specPackagePath": stringValue, "sourceNonClaimRefs": stringsValue, + "sourceNonClaims": stringsValue, "nonClaimDefinitions": array(nonClaimValue, limitDefinitions, limits), "vocabulary": array(termValue, limitTerms, limits), "derivations": array(derivationValue, limitDerivations, limits), diff --git a/internal/kernel/requirementsourcecodec/testdata/codec-field-manifest.v1.json b/internal/kernel/requirementsourcecodec/testdata/codec-field-manifest.v1.json index ba02a3e..a3e76d0 100644 --- a/internal/kernel/requirementsourcecodec/testdata/codec-field-manifest.v1.json +++ b/internal/kernel/requirementsourcecodec/testdata/codec-field-manifest.v1.json @@ -39,6 +39,7 @@ {"name": "kind", "type": "string", "required": true, "nullable": false, "constant": "proofkit.requirement-source"}, {"name": "sourceId", "type": "string", "required": true, "nullable": false}, {"name": "specPackagePath", "type": "string", "required": true, "nullable": false}, + {"name": "sourceNonClaims", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"}, {"name": "sourceNonClaimRefs", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"}, {"name": "nonClaimDefinitions", "type": "array:record:nonClaimDefinition", "required": true, "nullable": false, "limitOwner": "max_definitions"}, {"name": "vocabulary", "type": "array:record:vocabularyTerm", "required": true, "nullable": false, "limitOwner": "max_terms"}, @@ -93,6 +94,9 @@ { "recordId": "metadataFields", "fields": [ + {"name": "nonClaims", "type": "array:string", "required": false, "nullable": false, "limitOwner": "max_collection_items"}, + {"name": "externalNonClaimRefs", "type": "array:string", "required": false, "nullable": false, "limitOwner": "max_collection_items"}, + {"name": "proofBindingRefs", "type": "array:string", "required": false, "nullable": false, "limitOwner": "max_collection_items"}, {"name": "ownerId", "type": "string", "required": false, "nullable": false}, {"name": "claimLevel", "type": "string", "required": false, "nullable": false}, {"name": "riskClass", "type": "string", "required": false, "nullable": false}, diff --git a/internal/kernel/requirementsourcecodec/testdata/codec-limit-coefficients.v1.json b/internal/kernel/requirementsourcecodec/testdata/codec-limit-coefficients.v1.json index 6b2c478..35dbac2 100644 --- a/internal/kernel/requirementsourcecodec/testdata/codec-limit-coefficients.v1.json +++ b/internal/kernel/requirementsourcecodec/testdata/codec-limit-coefficients.v1.json @@ -3,7 +3,7 @@ "kind": "proofkit.requirement-source-codec-limit-coefficients", "canonicalByteBaseOverhead": 4096, "lexicalTokenBaseOverhead": 1024, - "minimumJsonNesting": 7, + "minimumJsonNesting": 9, "maximumJsonNesting": 64, "canonicalByteCoefficients": [ {"id": "collection_items", "coefficient": 32}, diff --git a/internal/kernel/requirementsourcemodel/clone.go b/internal/kernel/requirementsourcemodel/clone.go index e585b8b..8de0f20 100644 --- a/internal/kernel/requirementsourcemodel/clone.go +++ b/internal/kernel/requirementsourcemodel/clone.go @@ -16,6 +16,7 @@ func cloneDraft(value Draft) Draft { return Draft{ SourceID: value.SourceID, SpecPackagePath: value.SpecPackagePath, + SourceNonClaims: cloneStrings(value.SourceNonClaims), SourceNonClaimRefs: cloneStrings(value.SourceNonClaimRefs), NonClaimDefinitions: append([]NonClaimDefinition(nil), value.NonClaimDefinitions...), Vocabulary: append([]VocabularyTerm(nil), value.Vocabulary...), @@ -30,6 +31,7 @@ func cloneAtomicProjection(value AtomicProjection) AtomicProjection { return AtomicProjection{ SourceID: value.SourceID, SpecPackagePath: value.SpecPackagePath, + SourceNonClaims: cloneStrings(value.SourceNonClaims), SourceNonClaimRefs: cloneStrings(value.SourceNonClaimRefs), NonClaimDefinitions: append([]NonClaimDefinition(nil), value.NonClaimDefinitions...), Vocabulary: append([]VocabularyTerm(nil), value.Vocabulary...), @@ -42,16 +44,19 @@ func cloneAtomicRequirements(values []AtomicRequirement) []AtomicRequirement { result := make([]AtomicRequirement, len(values)) for index, value := range values { result[index] = AtomicRequirement{ - RequirementID: value.RequirementID, - Invariant: value.Invariant, - SharedPremises: cloneStrings(value.SharedPremises), - OwnerID: value.OwnerID, - ClaimLevel: value.ClaimLevel, - RiskClass: value.RiskClass, - NonClaimRefs: cloneStrings(value.NonClaimRefs), - Lifecycle: cloneLifecycle(value.Lifecycle), - Deferral: cloneDeferral(value.Deferral), - UpdatePolicy: value.UpdatePolicy, + RequirementID: value.RequirementID, + Invariant: value.Invariant, + SharedPremises: cloneStrings(value.SharedPremises), + OwnerID: value.OwnerID, + ClaimLevel: value.ClaimLevel, + RiskClass: value.RiskClass, + NonClaimRefs: cloneStrings(value.NonClaimRefs), + NonClaims: cloneStrings(value.NonClaims), + ExternalNonClaimRefs: cloneStrings(value.ExternalNonClaimRefs), + ProofBindingRefs: cloneStrings(value.ProofBindingRefs), + Lifecycle: cloneLifecycle(value.Lifecycle), + Deferral: cloneDeferral(value.Deferral), + UpdatePolicy: value.UpdatePolicy, } } return result @@ -110,6 +115,15 @@ func cloneGroup(value Group) Group { func cloneMetadataFields(value MetadataFields) MetadataFields { result := value + if value.NonClaims.Present { + result.NonClaims.Value = cloneStrings(value.NonClaims.Value) + } + if value.ExternalNonClaimRefs.Present { + result.ExternalNonClaimRefs.Value = cloneStrings(value.ExternalNonClaimRefs.Value) + } + if value.ProofBindingRefs.Present { + result.ProofBindingRefs.Value = cloneStrings(value.ProofBindingRefs.Value) + } if value.NonClaimRefs.Present { result.NonClaimRefs.Value = cloneStrings(value.NonClaimRefs.Value) } diff --git a/internal/kernel/requirementsourcemodel/draft_observation_test.go b/internal/kernel/requirementsourcemodel/draft_observation_test.go index c3cbf5a..50783e6 100644 --- a/internal/kernel/requirementsourcemodel/draft_observation_test.go +++ b/internal/kernel/requirementsourcemodel/draft_observation_test.go @@ -48,6 +48,8 @@ func observeDraftField(draft Draft, fieldID string) (any, bool) { return draft.SpecPackagePath, true case "source.nonClaimRefs": return sortedObservationStrings(draft.SourceNonClaimRefs), true + case "source.nonClaims": + return sortedObservationStrings(draft.SourceNonClaims), true case "nonClaim.id": return projectIdentities(draft.NonClaimDefinitions, func(value NonClaimDefinition) string { return value.NonClaimID }), true case "nonClaim.statement": diff --git a/internal/kernel/requirementsourcemodel/field_inventory_test.go b/internal/kernel/requirementsourcemodel/field_inventory_test.go index d1b3c6e..70cd4d2 100644 --- a/internal/kernel/requirementsourcemodel/field_inventory_test.go +++ b/internal/kernel/requirementsourcemodel/field_inventory_test.go @@ -35,6 +35,7 @@ var inputFieldManifestIDs = map[string][]string{ "Draft.Scenarios": nil, "Draft.SourceID": {"source.id"}, "Draft.SourceNonClaimRefs": {"source.nonClaimRefs"}, + "Draft.SourceNonClaims": {"source.nonClaims"}, "Draft.SpecPackagePath": {"source.specPackagePath"}, "Draft.Vocabulary": nil, "Example.ExampleID": {"scenario.example.id"}, @@ -58,6 +59,9 @@ var inputFieldManifestIDs = map[string][]string{ "MetadataFields.Deferral": {"metadata.deferral.presence"}, "MetadataFields.Lifecycle": nil, "MetadataFields.NonClaimRefs": {"metadata.nonClaimRefs"}, + "MetadataFields.NonClaims": {"metadata.nonClaims"}, + "MetadataFields.ExternalNonClaimRefs": {"metadata.externalNonClaimRefs"}, + "MetadataFields.ProofBindingRefs": {"metadata.proofBindingRefs"}, "MetadataFields.OwnerID": {"metadata.ownerId"}, "MetadataFields.RiskClass": {"metadata.riskClass"}, "MetadataFields.UpdatePolicy": nil, diff --git a/internal/kernel/requirementsourcemodel/group_normalization.go b/internal/kernel/requirementsourcemodel/group_normalization.go index 3775dee..40248b4 100644 --- a/internal/kernel/requirementsourcemodel/group_normalization.go +++ b/internal/kernel/requirementsourcemodel/group_normalization.go @@ -73,7 +73,10 @@ func normalizeGroups(values []Group, profiles map[string]Profile) ([]Group, []At } invariant := completion if stem != "" { - invariant = stem + " " + completion + invariant, err = canonicalText(stem+" "+completion, memberPath+"statementCompletion", false, true) + if err != nil { + return nil, nil, nil, nil, err + } } resolved.RequirementID = requirementID resolved.Invariant = invariant @@ -108,7 +111,7 @@ func normalizeGroups(values []Group, profiles map[string]Profile) ([]Group, []At func validateProfileUses(profiles []Profile, uses map[string]int) error { for _, profile := range profiles { if uses[profile.ProfileID] < 2 { - return invalid("vacuous_profile", "profiles."+profile.ProfileID) + return invalid("vacuous_profile", identified("profiles", profile.ProfileID)) } } return nil @@ -116,7 +119,7 @@ func validateProfileUses(profiles []Profile, uses map[string]int) error { func validateRequirementLifecycles(requirements []AtomicRequirement, byID map[string]AtomicRequirement) error { for _, requirement := range requirements { - path := "requirements." + requirement.RequirementID + path := identified("requirements", requirement.RequirementID) if requirement.ClaimLevel == ClaimDeferred && requirement.Deferral == nil { return invalid("missing_deferral", path+".deferral") } @@ -136,6 +139,9 @@ func validateRequirementLifecycles(requirements []AtomicRequirement, byID map[st return invalid("nonactive_blocking_requirement", path+".claimLevel") } if requirement.ClaimLevel == ClaimBlocking && requirement.Lifecycle.State == LifecycleActive { + if len(requirement.ProofBindingRefs) == 0 { + return invalid("missing_proof_binding", path+".proofBindingRefs") + } if !requirement.UpdatePolicy.RequiresImpactDeclaration { return invalid("impact_review_required", path+".updatePolicy.requiresImpactDeclaration") } diff --git a/internal/kernel/requirementsourcemodel/limits.go b/internal/kernel/requirementsourcemodel/limits.go index f517f42..430da38 100644 --- a/internal/kernel/requirementsourcemodel/limits.go +++ b/internal/kernel/requirementsourcemodel/limits.go @@ -161,7 +161,7 @@ func collectionItemsWithinBudget(draft Draft, limit int) bool { remaining -= value return true } - if !add(len(draft.SourceNonClaimRefs)) || !add(len(draft.NonClaimDefinitions)) || !add(len(draft.Vocabulary)) || + if !add(len(draft.SourceNonClaims)) || !add(len(draft.SourceNonClaimRefs)) || !add(len(draft.NonClaimDefinitions)) || !add(len(draft.Vocabulary)) || !add(len(draft.Derivations)) || !add(len(draft.Profiles)) || !add(len(draft.Groups)) || !add(len(draft.Scenarios)) { return false } @@ -201,6 +201,15 @@ func collectionItemsWithinBudget(draft Draft, limit int) bool { } func metadataCollectionsWithinBudget(fields MetadataFields, add func(int) bool) bool { + if fields.NonClaims.Present && !add(len(fields.NonClaims.Value)) { + return false + } + if fields.ExternalNonClaimRefs.Present && !add(len(fields.ExternalNonClaimRefs.Value)) { + return false + } + if fields.ProofBindingRefs.Present && !add(len(fields.ProofBindingRefs.Value)) { + return false + } if fields.NonClaimRefs.Present && !add(len(fields.NonClaimRefs.Value)) { return false } @@ -230,6 +239,7 @@ func decimal(value int) string { func draftTextBytes(draft Draft) uint64 { total := textBytes(draft.SourceID, draft.SpecPackagePath) + total += stringsBytes(draft.SourceNonClaims) total += stringsBytes(draft.SourceNonClaimRefs) for _, definition := range draft.NonClaimDefinitions { total += textBytes(definition.NonClaimID, definition.Statement) @@ -267,6 +277,15 @@ func draftTextBytes(draft Draft) uint64 { func metadataTextBytes(fields MetadataFields) uint64 { total := uint64(0) + if fields.NonClaims.Present { + total += stringsBytes(fields.NonClaims.Value) + } + if fields.ExternalNonClaimRefs.Present { + total += stringsBytes(fields.ExternalNonClaimRefs.Value) + } + if fields.ProofBindingRefs.Present { + total += stringsBytes(fields.ProofBindingRefs.Value) + } if fields.OwnerID.Present { total += uint64(len(fields.OwnerID.Value)) } diff --git a/internal/kernel/requirementsourcemodel/metadata.go b/internal/kernel/requirementsourcemodel/metadata.go index 1e463ad..d1b5632 100644 --- a/internal/kernel/requirementsourcemodel/metadata.go +++ b/internal/kernel/requirementsourcemodel/metadata.go @@ -5,9 +5,12 @@ import "reflect" var metadataFieldIDs = []MetadataFieldID{ "claimLevel", "deferral", + "externalNonClaimRefs", "lifecycle", "nonClaimRefs", + "nonClaims", "ownerId", + "proofBindingRefs", "riskClass", "updatePolicy", } @@ -18,6 +21,24 @@ func normalizeMetadataFields(value MetadataFields, path string) (MetadataFields, } result := value var err error + if value.NonClaims.Present { + result.NonClaims.Value, err = normalizeTexts(value.NonClaims.Value, path+".nonClaims", true, false) + if err != nil { + return MetadataFields{}, err + } + } + if value.ExternalNonClaimRefs.Present { + result.ExternalNonClaimRefs.Value, err = normalizeIDs(value.ExternalNonClaimRefs.Value, "", path+".externalNonClaimRefs", true) + if err != nil { + return MetadataFields{}, err + } + } + if value.ProofBindingRefs.Present { + result.ProofBindingRefs.Value, err = normalizePaths(value.ProofBindingRefs.Value, path+".proofBindingRefs", true) + if err != nil { + return MetadataFields{}, err + } + } if value.OwnerID.Present { result.OwnerID.Value, err = canonicalExternalID(value.OwnerID.Value, path+".ownerId") if err != nil { @@ -68,6 +89,9 @@ func rejectHiddenMetadataValues(value MetadataFields, path string) error { zero any field string }{ + {value.NonClaims.Present, value.NonClaims.Value, []string(nil), "nonClaims"}, + {value.ExternalNonClaimRefs.Present, value.ExternalNonClaimRefs.Value, []string(nil), "externalNonClaimRefs"}, + {value.ProofBindingRefs.Present, value.ProofBindingRefs.Value, []string(nil), "proofBindingRefs"}, {value.OwnerID.Present, value.OwnerID.Value, "", "ownerId"}, {value.ClaimLevel.Present, value.ClaimLevel.Value, ClaimLevel(""), "claimLevel"}, {value.RiskClass.Present, value.RiskClass.Value, RiskClass(""), "riskClass"}, @@ -155,13 +179,16 @@ func metadataFieldCount(value MetadataFields) int { func metadataPresence(value MetadataFields) map[MetadataFieldID]bool { return map[MetadataFieldID]bool{ - "ownerId": value.OwnerID.Present, - "claimLevel": value.ClaimLevel.Present, - "riskClass": value.RiskClass.Present, - "nonClaimRefs": value.NonClaimRefs.Present, - "lifecycle": value.Lifecycle.Present, - "deferral": value.Deferral.Present, - "updatePolicy": value.UpdatePolicy.Present, + "nonClaims": value.NonClaims.Present, + "externalNonClaimRefs": value.ExternalNonClaimRefs.Present, + "proofBindingRefs": value.ProofBindingRefs.Present, + "ownerId": value.OwnerID.Present, + "claimLevel": value.ClaimLevel.Present, + "riskClass": value.RiskClass.Present, + "nonClaimRefs": value.NonClaimRefs.Present, + "lifecycle": value.Lifecycle.Present, + "deferral": value.Deferral.Present, + "updatePolicy": value.UpdatePolicy.Present, } } @@ -185,6 +212,21 @@ func resolveMetadata(profileID string, memberID string, profile MetadataFields, owners = append(owners, FieldOwner{FieldID: fieldID, OwnerKind: ownerKind, OwnerID: ownerID}) } result := AtomicRequirement{} + if profile.NonClaims.Present { + result.NonClaims = cloneStrings(profile.NonClaims.Value) + } else { + result.NonClaims = cloneStrings(member.NonClaims.Value) + } + if profile.ExternalNonClaimRefs.Present { + result.ExternalNonClaimRefs = cloneStrings(profile.ExternalNonClaimRefs.Value) + } else { + result.ExternalNonClaimRefs = cloneStrings(member.ExternalNonClaimRefs.Value) + } + if profile.ProofBindingRefs.Present { + result.ProofBindingRefs = cloneStrings(profile.ProofBindingRefs.Value) + } else { + result.ProofBindingRefs = cloneStrings(member.ProofBindingRefs.Value) + } if profile.OwnerID.Present { result.OwnerID = profile.OwnerID.Value } else { diff --git a/internal/kernel/requirementsourcemodel/metadata_ownership_test.go b/internal/kernel/requirementsourcemodel/metadata_ownership_test.go index 3cb99e0..c2df6cd 100644 --- a/internal/kernel/requirementsourcemodel/metadata_ownership_test.go +++ b/internal/kernel/requirementsourcemodel/metadata_ownership_test.go @@ -8,9 +8,12 @@ import ( var independentlyExpectedMetadataFieldIDs = []MetadataFieldID{ "claimLevel", "deferral", + "externalNonClaimRefs", "lifecycle", "nonClaimRefs", + "nonClaims", "ownerId", + "proofBindingRefs", "riskClass", "updatePolicy", } @@ -205,6 +208,12 @@ func setMetadataField(target *MetadataFields, fieldID MetadataFieldID, source Me target.RiskClass = value.RiskClass case "nonClaimRefs": target.NonClaimRefs = value.NonClaimRefs + case "nonClaims": + target.NonClaims = value.NonClaims + case "externalNonClaimRefs": + target.ExternalNonClaimRefs = value.ExternalNonClaimRefs + case "proofBindingRefs": + target.ProofBindingRefs = value.ProofBindingRefs case "lifecycle": target.Lifecycle = value.Lifecycle case "deferral": @@ -224,6 +233,12 @@ func clearMetadataField(target *MetadataFields, fieldID MetadataFieldID) { target.RiskClass = Field[RiskClass]{} case "nonClaimRefs": target.NonClaimRefs = Field[[]string]{} + case "nonClaims": + target.NonClaims = Field[[]string]{} + case "externalNonClaimRefs": + target.ExternalNonClaimRefs = Field[[]string]{} + case "proofBindingRefs": + target.ProofBindingRefs = Field[[]string]{} case "lifecycle": target.Lifecycle = Field[Lifecycle]{} case "deferral": diff --git a/internal/kernel/requirementsourcemodel/model_test.go b/internal/kernel/requirementsourcemodel/model_test.go index 7363f2e..1384884 100644 --- a/internal/kernel/requirementsourcemodel/model_test.go +++ b/internal/kernel/requirementsourcemodel/model_test.go @@ -270,7 +270,7 @@ func TestNormalizeReportsUnreferencedDefinitionsDeterministically(t *testing.T) NonClaimDefinition{NonClaimID: "NCL-MODEL-006", Statement: "This sixth declaration remains deliberately unreferenced."}, NonClaimDefinition{NonClaimID: "NCL-MODEL-005", Statement: "This fifth declaration remains deliberately unreferenced."}, ) - const expected = "unreferenced_definition: nonClaimDefinitions.NCL-MODEL-005" + const expected = `unreferenced_definition: nonClaimDefinitions["NCL-MODEL-005"]` for attempt := 0; attempt < 100; attempt++ { _, err := Normalize(draft) if err == nil || err.Error() != expected { @@ -330,19 +330,25 @@ func validDraft() Draft { } memberFields := func() MetadataFields { return MetadataFields{ - NonClaimRefs: Own([]string{"NCL-MODEL-002"}), - Lifecycle: Own(active), - Deferral: Own[*Deferral](nil), + NonClaims: Own([]string{"Requirement admission does not execute native witnesses."}), + ExternalNonClaimRefs: Own([]string{"proofkit.nonclaim.native"}), + ProofBindingRefs: Own([]string{"proofkit/requirement-bindings.json"}), + NonClaimRefs: Own([]string{"NCL-MODEL-002"}), + Lifecycle: Own(active), + Deferral: Own[*Deferral](nil), } } fullFields := func(claim ClaimLevel, lifecycle Lifecycle, deferral *Deferral) MetadataFields { return MetadataFields{ - OwnerID: Own("proofkit.model"), - ClaimLevel: Own(claim), - RiskClass: Own(RiskMedium), - NonClaimRefs: Own([]string{"NCL-MODEL-002"}), - Lifecycle: Own(lifecycle), - Deferral: Own(deferral), + NonClaims: Own([]string{"Requirement admission does not execute native witnesses."}), + ExternalNonClaimRefs: Own([]string{"proofkit.nonclaim.native"}), + ProofBindingRefs: Own([]string{"proofkit/requirement-bindings.json"}), + OwnerID: Own("proofkit.model"), + ClaimLevel: Own(claim), + RiskClass: Own(RiskMedium), + NonClaimRefs: Own([]string{"NCL-MODEL-002"}), + Lifecycle: Own(lifecycle), + Deferral: Own(deferral), UpdatePolicy: Own(UpdatePolicy{ ReviewOwnerID: "proofkit.model", RequiresImpactDeclaration: true, @@ -361,6 +367,7 @@ func validDraft() Draft { return Draft{ SourceID: "proofkit.model.source", SpecPackagePath: "docs/specs/proofkit-model", + SourceNonClaims: []string{"Source admission does not execute native witnesses."}, SourceNonClaimRefs: []string{"NCL-MODEL-001"}, NonClaimDefinitions: []NonClaimDefinition{ {NonClaimID: "NCL-MODEL-001", Statement: "The model does not prove implementation correctness."}, diff --git a/internal/kernel/requirementsourcemodel/mutants_test.go b/internal/kernel/requirementsourcemodel/mutants_test.go index a0ed901..1cf84c1 100644 --- a/internal/kernel/requirementsourcemodel/mutants_test.go +++ b/internal/kernel/requirementsourcemodel/mutants_test.go @@ -2,6 +2,14 @@ package requirementsourcemodel func mutantImplementations() map[string]func(*Draft) { return map[string]func(*Draft){ + "source-direct-nonclaims": func(draft *Draft) { draft.SourceNonClaims[0] += " This is a declaration." }, + "metadata-direct-nonclaims": func(draft *Draft) { draft.Groups[0].Members[0].Fields.NonClaims.Value[0] += " This is a declaration." }, + "metadata-external-nonclaim-refs": func(draft *Draft) { + draft.Groups[0].Members[0].Fields.ExternalNonClaimRefs.Value[0] = "proofkit.nonclaim.external" + }, + "metadata-proof-binding-refs": func(draft *Draft) { + draft.Groups[0].Members[0].Fields.ProofBindingRefs.Value[0] = "proofkit/other-bindings.json" + }, "source-id": func(draft *Draft) { draft.SourceID = "proofkit.model.changed" }, "source-package-path": func(draft *Draft) { draft.SpecPackagePath = "docs/specs/proofkit-model-v2" }, "source-nonclaim-refs": func(draft *Draft) { draft.SourceNonClaimRefs = []string{"NCL-MODEL-001", "NCL-MODEL-002"} }, diff --git a/internal/kernel/requirementsourcemodel/non_claim_scopes.go b/internal/kernel/requirementsourcemodel/non_claim_scopes.go new file mode 100644 index 0000000..fd94bdd --- /dev/null +++ b/internal/kernel/requirementsourcemodel/non_claim_scopes.go @@ -0,0 +1,34 @@ +package requirementsourcemodel + +// Reference closure precedes this check. Text equality does not imply shared +// ownership; it is a duplicate only inside one effective boundary scope. +func validateNonClaimScopes(source []string, sourceRefs []string, definitions []NonClaimDefinition, requirements []AtomicRequirement) error { + statements := make(map[string]string, len(definitions)) + for _, definition := range definitions { + statements[definition.NonClaimID] = definition.Statement + } + if err := validateNonClaimScope(source, sourceRefs, statements, "sourceNonClaimRefs"); err != nil { + return err + } + for _, requirement := range requirements { + if err := validateNonClaimScope(requirement.NonClaims, requirement.NonClaimRefs, statements, identified("requirements", requirement.RequirementID)+".nonClaimRefs"); err != nil { + return err + } + } + return nil +} + +func validateNonClaimScope(direct []string, refs []string, statements map[string]string, refsPath string) error { + seen := make(map[string]struct{}, len(direct)+len(refs)) + for _, statement := range direct { + seen[statement] = struct{}{} + } + for _, ref := range refs { + statement := statements[ref] + if _, exists := seen[statement]; exists { + return invalid("duplicate_effective_nonclaim", refsPath) + } + seen[statement] = struct{}{} + } + return nil +} diff --git a/internal/kernel/requirementsourcemodel/non_claim_scopes_test.go b/internal/kernel/requirementsourcemodel/non_claim_scopes_test.go new file mode 100644 index 0000000..135bbd8 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/non_claim_scopes_test.go @@ -0,0 +1,172 @@ +package requirementsourcemodel + +import ( + "fmt" + "reflect" + "strings" + "testing" +) + +func TestNonClaimTextDoesNotMergeIndependentOwners(t *testing.T) { + draft := validDraft() + const statement = "Admission does not execute native witnesses." + draft.SourceNonClaims = []string{statement} + for i := range draft.Groups[0].Members { + draft.Groups[0].Members[i].Fields.NonClaims = Own([]string{statement}) + } + baseline, err := Normalize(draft) + if err != nil { + t.Fatal(err) + } + draft.SourceNonClaims[0] = "Source-level owner text changed." + afterSource, err := Normalize(draft) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(baseline.Atomic().Requirements, afterSource.Atomic().Requirements) || !reflect.DeepEqual(baseline.Layout(), afterSource.Layout()) { + t.Fatal("editing direct source text changed an independent requirement owner") + } + draft.Groups[0].Members[0].Fields.NonClaims.Value[0] = "First requirement owner text changed." + afterMember, err := Normalize(draft) + if err != nil { + t.Fatal(err) + } + if got := afterMember.Atomic().Requirements[1].NonClaims; !reflect.DeepEqual(got, []string{statement}) { + t.Fatalf("editing one direct member changed another: %#v", got) + } + if !reflect.DeepEqual(afterSource.References(), afterMember.References()) { + t.Fatal("direct text created or changed a named reference") + } +} + +func TestEqualDefinitionTextIsScopedByReferenceOwnership(t *testing.T) { + draft := validDraft() + draft.NonClaimDefinitions[1].Statement = draft.NonClaimDefinitions[0].Statement + if _, err := Normalize(draft); err != nil { + t.Fatalf("equal text in separate named scopes was rejected: %v", err) + } + draft.SourceNonClaimRefs = append(draft.SourceNonClaimRefs, draft.NonClaimDefinitions[1].NonClaimID) + if _, err := Normalize(draft); ErrorCode(err) != "duplicate_effective_nonclaim" { + t.Fatalf("equal named statements in one scope: %v", err) + } +} + +func TestMetadataBoundaryAdmissionRejectsIndependentCounterexamples(t *testing.T) { + tests := []struct { + name string + code string + edit func(*Draft) + }{ + {"empty source boundary", "empty_source_nonclaims", func(d *Draft) { d.SourceNonClaims = nil; d.SourceNonClaimRefs = nil }}, + {"duplicate direct source", "duplicate_value", func(d *Draft) { d.SourceNonClaims = append(d.SourceNonClaims, d.SourceNonClaims[0]) }}, + {"source direct and named", "duplicate_effective_nonclaim", func(d *Draft) { d.SourceNonClaims = []string{d.NonClaimDefinitions[0].Statement} }}, + {"member direct and named", "duplicate_effective_nonclaim", func(d *Draft) { + d.Groups[0].Members[0].Fields.NonClaims.Value = []string{d.NonClaimDefinitions[1].Statement} + }}, + {"duplicate direct member", "duplicate_value", func(d *Draft) { + f := &d.Groups[0].Members[0].Fields + f.NonClaims.Value = append(f.NonClaims.Value, f.NonClaims.Value[0]) + }}, + {"empty active binding route", "missing_proof_binding", func(d *Draft) { d.Groups[0].Members[0].Fields.ProofBindingRefs.Value = nil }}, + {"traversal binding route", "invalid_path", func(d *Draft) { d.Groups[0].Members[0].Fields.ProofBindingRefs.Value = []string{"../bindings.json"} }}, + {"duplicate binding route", "duplicate_value", func(d *Draft) { + f := &d.Groups[0].Members[0].Fields + f.ProofBindingRefs.Value = append(f.ProofBindingRefs.Value, f.ProofBindingRefs.Value[0]) + }}, + {"invalid external ID", "invalid_id", func(d *Draft) { + d.Groups[0].Members[0].Fields.ExternalNonClaimRefs.Value = []string{"invalid external id"} + }}, + {"duplicate external ID", "duplicate_value", func(d *Draft) { + d.Groups[0].Members[0].Fields.ExternalNonClaimRefs.Value = []string{"external.id", "external.id"} + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + draft := validDraft() + tt.edit(&draft) + if _, err := Normalize(draft); ErrorCode(err) != tt.code { + t.Fatalf("error = %v, want %s", err, tt.code) + } + }) + } +} + +func TestExternalNonClaimRefsRemainIndependentDeclarations(t *testing.T) { + draft := validDraft() + draft.Groups[0].Members[0].Fields.ExternalNonClaimRefs.Value = []string{"NCL-UNRESOLVED", "external.nonclaim.other"} + draft.Groups[0].Members[1].Fields.ExternalNonClaimRefs.Value = nil + draft.Groups[1].Members[0].Fields.ProofBindingRefs.Value = nil + model, err := Normalize(draft) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(model.Atomic().Requirements[0].ExternalNonClaimRefs, []string{"NCL-UNRESOLVED", "external.nonclaim.other"}) { + t.Fatal("independent external IDs were rewritten or erased") + } + for _, edge := range model.References().Edges { + if edge.To.ID == "NCL-UNRESOLVED" || edge.To.ID == "external.nonclaim.other" { + t.Fatal("external declaration was laundered into local reference closure") + } + } +} + +func TestDirectNonClaimAdmissionDoesNotDiscloseRejectedText(t *testing.T) { + const sentinel = "ghp_0123456789abcdefghijklmnopqrstuvwxyz" + for _, source := range []bool{false, true} { + draft := validDraft() + if source { + draft.SourceNonClaims = []string{sentinel} + } else { + draft.Groups[0].Members[0].Fields.NonClaims.Value = []string{sentinel} + } + _, err := Normalize(draft) + if ErrorCode(err) != "invalid_text" || strings.Contains(err.Error(), sentinel) { + t.Fatal("direct statement admission failed its nondisclosure boundary") + } + } +} + +func TestBoundaryMetadataRejectsHiddenPayloads(t *testing.T) { + for _, field := range []struct { + id MetadataFieldID + get func(*MetadataFields) *Field[[]string] + }{ + {"nonClaims", func(f *MetadataFields) *Field[[]string] { return &f.NonClaims }}, + {"externalNonClaimRefs", func(f *MetadataFields) *Field[[]string] { return &f.ExternalNonClaimRefs }}, + {"proofBindingRefs", func(f *MetadataFields) *Field[[]string] { return &f.ProofBindingRefs }}, + } { + for _, profile := range []bool{false, true} { + for _, size := range []int{1, 1000} { + t.Run(fmt.Sprintf("%s/profile=%t/size=%d", field.id, profile, size), func(t *testing.T) { + draft := validDraft() + if profile { + moveMetadataFieldToOtherOwner(&draft, field.id) + } + fields := &draft.Groups[0].Members[0].Fields + if profile { + fields = &draft.Profiles[0].Fields + } + payload := make([]string, size) + *field.get(fields) = Field[[]string]{Value: payload} + if _, err := NormalizeWithLimits(draft, DefaultLimits()); ErrorCode(err) != "hidden_field_payload" { + t.Fatalf("hidden metadata field: %v", err) + } + cloned := cloneMetadataFields(*fields) + if value := field.get(&cloned); value.Present || len(value.Value) != size || &value.Value[0] != &payload[0] { + t.Fatal("clone copied or erased an absent payload before admission") + } + field.get(fields).Present = true + cloned = cloneMetadataFields(*fields) + value := field.get(&cloned) + if !value.Present || len(value.Value) != size || &value.Value[0] == &payload[0] { + t.Fatal("present payload was not detached") + } + value.Value[0] = "Changed detached payload." + if payload[0] != "" { + t.Fatal("editing a cloned value changed caller storage") + } + }) + } + } + } +} diff --git a/internal/kernel/requirementsourcemodel/normalize.go b/internal/kernel/requirementsourcemodel/normalize.go index 233f0ad..ecb4a58 100644 --- a/internal/kernel/requirementsourcemodel/normalize.go +++ b/internal/kernel/requirementsourcemodel/normalize.go @@ -35,6 +35,13 @@ func NormalizeWithLimits(draft Draft, limits Limits) (Model, error) { if err != nil { return Model{}, err } + sourceNonClaims, err := normalizeTexts(snapshot.SourceNonClaims, "sourceNonClaims", true, false) + if err != nil { + return Model{}, err + } + if len(sourceNonClaims) == 0 && len(sourceNonClaimRefs) == 0 { + return Model{}, invalid("empty_source_nonclaims", "sourceNonClaims") + } definitions, definitionIDs, err := normalizeDefinitions(snapshot.NonClaimDefinitions) if err != nil { @@ -75,10 +82,14 @@ func NormalizeWithLimits(draft Draft, limits Limits) (Model, error) { if err := validateReferenceClosure(definitionIDs, vocabularyIDs, edges); err != nil { return Model{}, err } + if err := validateNonClaimScopes(sourceNonClaims, sourceNonClaimRefs, definitions, requirements); err != nil { + return Model{}, err + } atomic := AtomicProjection{ SourceID: sourceID, SpecPackagePath: specPackagePath, + SourceNonClaims: sourceNonClaims, SourceNonClaimRefs: sourceNonClaimRefs, NonClaimDefinitions: definitions, Vocabulary: vocabulary, @@ -97,7 +108,6 @@ func NormalizeWithLimits(draft Draft, limits Limits) (Model, error) { func normalizeDefinitions(values []NonClaimDefinition) ([]NonClaimDefinition, map[string]struct{}, error) { result := make([]NonClaimDefinition, len(values)) ids := make(map[string]struct{}, len(values)) - statements := make(map[string]struct{}, len(values)) for index, value := range values { path := indexed("nonClaimDefinitions", index, "") id, err := canonicalID(value.NonClaimID, "NCL-", path+"nonClaimId") @@ -111,11 +121,7 @@ func normalizeDefinitions(values []NonClaimDefinition) ([]NonClaimDefinition, ma if err != nil { return nil, nil, err } - if _, exists := statements[statement]; exists { - return nil, nil, invalid("duplicate_definition", "nonClaimDefinitions") - } ids[id] = struct{}{} - statements[statement] = struct{}{} result[index] = NonClaimDefinition{NonClaimID: id, Statement: statement} } sort.Slice(result, func(left int, right int) bool { return result[left].NonClaimID < result[right].NonClaimID }) diff --git a/internal/kernel/requirementsourcemodel/package_boundary_test.go b/internal/kernel/requirementsourcemodel/package_boundary_test.go index a4d18d8..8de6867 100644 --- a/internal/kernel/requirementsourcemodel/package_boundary_test.go +++ b/internal/kernel/requirementsourcemodel/package_boundary_test.go @@ -59,6 +59,7 @@ func TestRepresentationNeutralPackageBoundaryIsExact(t *testing.T) { "reflect", "regexp", "sort", + "strconv", "strings", "unicode/utf8", } diff --git a/internal/kernel/requirementsourcemodel/projection_budget.go b/internal/kernel/requirementsourcemodel/projection_budget.go index 6d6d696..d4173cb 100644 --- a/internal/kernel/requirementsourcemodel/projection_budget.go +++ b/internal/kernel/requirementsourcemodel/projection_budget.go @@ -9,39 +9,48 @@ type projectionCost struct { type projectionBudget struct { cost projectionCost + limits Limits itemOverflow bool textOverflow bool } func (budget *projectionBudget) records(count int) { - if count < 0 || !addMultiplied(&budget.cost.Items, uint64(count), materializationCopies) { + if budget.itemOverflow { + return + } + if count < 0 || !addMultiplied(&budget.cost.Items, uint64(count), materializationCopies) || budget.cost.Items > uint64(budget.limits.MaxExpandedItems) { budget.itemOverflow = true } } func (budget *projectionBudget) text(values ...string) { - for _, value := range values { - budget.records(1) - budget.addTextBytes(value) - } + budget.texts(values) } func (budget *projectionBudget) composedText(values ...string) { budget.records(1) for _, value := range values { + if budget.itemOverflow || budget.textOverflow { + return + } budget.addTextBytes(value) } } func (budget *projectionBudget) addTextBytes(value string) { - if !addMultiplied(&budget.cost.TextBytes, uint64(len(value)), materializationCopies) { + if !addMultiplied(&budget.cost.TextBytes, uint64(len(value)), materializationCopies) || budget.cost.TextBytes > uint64(budget.limits.MaxExpandedTextBytes) { budget.textOverflow = true } } func (budget *projectionBudget) texts(values []string) { + // Count the expanded cardinality before visiting any collection payload. + budget.records(len(values)) for _, value := range values { - budget.text(value) + if budget.itemOverflow || budget.textOverflow { + return + } + budget.addTextBytes(value) } } @@ -59,7 +68,7 @@ func addMultiplied(total *uint64, value uint64, multiplier uint64) bool { } func preflightExpandedProjectionBudget(draft Draft, limits Limits) error { - cost, itemOverflow, textOverflow := estimateExpandedProjectionCost(draft) + cost, itemOverflow, textOverflow := estimateExpandedProjectionCost(draft, limits) if itemOverflow || cost.Items > uint64(limits.MaxExpandedItems) { return invalid("expanded_item_budget_exceeded", "draft") } @@ -69,8 +78,8 @@ func preflightExpandedProjectionBudget(draft Draft, limits Limits) error { return nil } -func estimateExpandedProjectionCost(draft Draft) (projectionCost, bool, bool) { - budget := &projectionBudget{} +func estimateExpandedProjectionCost(draft Draft, limits Limits) (projectionCost, bool, bool) { + budget := &projectionBudget{limits: limits} profiles := make(map[string]MetadataFields, len(draft.Profiles)) for _, profile := range draft.Profiles { profiles[profile.ProfileID] = profile.Fields @@ -78,6 +87,7 @@ func estimateExpandedProjectionCost(draft Draft) (projectionCost, bool, bool) { budget.records(3) budget.text(draft.SourceID, draft.SpecPackagePath) + budget.texts(draft.SourceNonClaims) budget.texts(draft.SourceNonClaimRefs) for _, definition := range draft.NonClaimDefinitions { budget.records(1) @@ -181,6 +191,15 @@ func estimateExpandedProjectionCost(draft Draft) (projectionCost, bool, bool) { func (budget *projectionBudget) layoutMetadata(fields MetadataFields) { budget.records(1 + len(metadataFieldIDs)) + if fields.NonClaims.Present { + budget.texts(fields.NonClaims.Value) + } + if fields.ExternalNonClaimRefs.Present { + budget.texts(fields.ExternalNonClaimRefs.Value) + } + if fields.ProofBindingRefs.Present { + budget.texts(fields.ProofBindingRefs.Value) + } if fields.OwnerID.Present { budget.text(fields.OwnerID.Value) } @@ -221,6 +240,9 @@ func (budget *projectionBudget) atomicMetadata(profile MetadataFields, member Me string(selectedField(profile.RiskClass, member.RiskClass)), ) budget.texts(selectedField(profile.NonClaimRefs, member.NonClaimRefs)) + budget.texts(selectedField(profile.NonClaims, member.NonClaims)) + budget.texts(selectedField(profile.ExternalNonClaimRefs, member.ExternalNonClaimRefs)) + budget.texts(selectedField(profile.ProofBindingRefs, member.ProofBindingRefs)) lifecycle := selectedField(profile.Lifecycle, member.Lifecycle) budget.text(string(lifecycle.State)) budget.texts(lifecycle.ReplacementRequirementIDs) @@ -237,7 +259,11 @@ func selectedField[T any](profile Field[T], member Field[T]) T { if profile.Present { return profile.Value } - return member.Value + if member.Present { + return member.Value + } + var absent T + return absent } func (budget *projectionBudget) scenario(value Scenario) { @@ -270,11 +296,17 @@ func (budget *projectionBudget) derivation(value Derivation) { func (budget *projectionBudget) metadataEdges(requirementID string, fields MetadataFields) { if fields.NonClaimRefs.Present { for _, nonClaimID := range fields.NonClaimRefs.Value { + if budget.itemOverflow { + return + } budget.edge(ReferenceRequirementNonClaim, EntityRequirement, requirementID, EntityNonClaim, nonClaimID) } } if fields.Lifecycle.Present { for _, replacementID := range fields.Lifecycle.Value.ReplacementRequirementIDs { + if budget.itemOverflow { + return + } budget.edge(ReferenceLifecycleReplacement, EntityRequirement, requirementID, EntityRequirement, replacementID) } } diff --git a/internal/kernel/requirementsourcemodel/projection_budget_test.go b/internal/kernel/requirementsourcemodel/projection_budget_test.go index d3782c4..d8829c5 100644 --- a/internal/kernel/requirementsourcemodel/projection_budget_test.go +++ b/internal/kernel/requirementsourcemodel/projection_budget_test.go @@ -7,13 +7,72 @@ import ( ) func TestExpandedProjectionBudgetMatchesIndependentMaterialization(t *testing.T) { + assertExpandedProjectionBudgets(t, validDraft()) +} + +func TestExpandedProjectionIgnoresAbsentPayloadBeforeAdmission(t *testing.T) { + draft := validDraft() + draft.Groups[0].Members[0].Fields.NonClaims = Field[[]string]{} + before, _, _ := estimateExpandedProjectionCost(draft, DefaultLimits()) + draft.Groups[0].Members[0].Fields.NonClaims.Value = make([]string, 1000) + after, _, _ := estimateExpandedProjectionCost(draft, DefaultLimits()) + if before != after { + t.Fatal("absent metadata payload contributed to expanded cost") + } + if _, err := Normalize(draft); ErrorCode(err) != "hidden_field_payload" { + t.Fatalf("hidden payload must still be rejected: %v", err) + } +} + +func TestProjectionBudgetStopsPayloadWorkButPreservesItemPrecedence(t *testing.T) { + limits := DefaultLimits() + limits.MaxExpandedItems = 4 + limits.MaxExpandedTextBytes = 1 + budget := projectionBudget{limits: limits} + budget.texts([]string{"first", "second", "third"}) + if !budget.itemOverflow || budget.cost.TextBytes != 0 || budget.textOverflow { + t.Fatal("collection cardinality did not dominate payload traversal") + } + + budget = projectionBudget{limits: limits} + budget.text("first") + if !budget.textOverflow || budget.itemOverflow || budget.cost.Items != 2 { + t.Fatal("text-only overflow did not preserve item accounting") + } + bytes := budget.cost.TextBytes + budget.texts([]string{"second", "third"}) + if !budget.itemOverflow || budget.cost.TextBytes != bytes { + t.Fatal("text overflow either suppressed item accounting or continued text work") + } + if err := preflightExpandedProjectionBudget(validDraft(), limits); ErrorCode(err) != "expanded_item_budget_exceeded" { + t.Fatalf("item overflow must dominate text overflow: %v", err) + } +} + +func TestExpandedBudgetRejectsRepeatedProfileBeforeSemanticWork(t *testing.T) { draft := validDraft() + moveMetadataFieldToOtherOwner(&draft, "nonClaims") + draft.Profiles[0].Fields.NonClaims.Value = make([]string, 1024) + for index := range draft.Profiles[0].Fields.NonClaims.Value { + draft.Profiles[0].Fields.NonClaims.Value[index] = "Boundary statement." + } + // Repetition is invalid, but the expanded budget must reject it before + // per-statement semantics or repeated materialization can become the guard. + limits := DefaultLimits() + limits.MaxExpandedItems = 2048 + if _, err := NormalizeWithLimits(draft, limits); ErrorCode(err) != "expanded_item_budget_exceeded" { + t.Fatalf("expanded profile was processed before its budget: %v", err) + } +} + +func assertExpandedProjectionBudgets(t *testing.T, draft Draft) { + t.Helper() model, err := Normalize(draft) if err != nil { t.Fatal(err) } - estimated, itemOverflow, textOverflow := estimateExpandedProjectionCost(draft) + estimated, itemOverflow, textOverflow := estimateExpandedProjectionCost(draft, DefaultLimits()) if itemOverflow || textOverflow { t.Fatalf("valid fixture overflowed estimate: item=%t text=%t", itemOverflow, textOverflow) } @@ -50,6 +109,36 @@ func TestExpandedProjectionBudgetMatchesIndependentMaterialization(t *testing.T) } } +func TestBoundaryMetadataBudgetsCoverBothLexicalOwners(t *testing.T) { + for _, fieldID := range []MetadataFieldID{"nonClaims", "externalNonClaimRefs", "proofBindingRefs"} { + for _, shared := range []bool{false, true} { + t.Run(string(fieldID)+"/"+map[bool]string{false: "member", true: "profile"}[shared], func(t *testing.T) { + draft := validDraft() + if shared { + moveMetadataFieldToOtherOwner(&draft, fieldID) + } + assertExpandedProjectionBudgets(t, draft) + items := observeInputCollectionItems(reflect.ValueOf(draft)) + text := observeStructuredCost(draft).TextBytes + limits := DefaultLimits() + limits.MaxCollectionItems, limits.MaxTotalTextBytes = int(items), int(text) + if _, err := NormalizeWithLimits(draft, limits); err != nil { + t.Fatalf("exact independent input budgets rejected: %v", err) + } + limits.MaxCollectionItems-- + if _, err := NormalizeWithLimits(draft, limits); ErrorCode(err) != "collection_item_budget_exceeded" { + t.Fatalf("input item limit-minus-one: %v", err) + } + limits.MaxCollectionItems++ + limits.MaxTotalTextBytes-- + if _, err := NormalizeWithLimits(draft, limits); ErrorCode(err) != "text_budget_exceeded" { + t.Fatalf("input byte limit-minus-one: %v", err) + } + }) + } + } +} + func observeStructuredCost(values ...any) projectionCost { cost := projectionCost{} for _, value := range values { diff --git a/internal/kernel/requirementsourcemodel/projection_observation_test.go b/internal/kernel/requirementsourcemodel/projection_observation_test.go index 9cfa9e5..8ee56c7 100644 --- a/internal/kernel/requirementsourcemodel/projection_observation_test.go +++ b/internal/kernel/requirementsourcemodel/projection_observation_test.go @@ -26,6 +26,8 @@ func observeManifestField(model Model, fieldID string, projection string) (any, } case "source.specPackagePath": return atomic.SpecPackagePath, projection == "atomic" + case "source.nonClaims": + return atomic.SourceNonClaims, projection == "atomic" case "source.nonClaimRefs": switch projection { case "atomic": @@ -87,6 +89,7 @@ func observeManifestField(model Model, fieldID string, projection string) (any, return observeEdges(references, ReferenceGroupProfile), true } case "metadata.ownerId", "metadata.claimLevel", "metadata.riskClass", "metadata.nonClaimRefs", + "metadata.nonClaims", "metadata.externalNonClaimRefs", "metadata.proofBindingRefs", "metadata.lifecycle.state", "metadata.lifecycle.replacementRequirementIds", "metadata.lifecycle.evidenceRefs", "metadata.deferral.presence", "metadata.deferral.ownerId", "metadata.deferral.riskAcceptedBy", "metadata.deferral.reviewCondition", "metadata.deferral.expiryRef", "metadata.deferral.mergePolicy", @@ -322,6 +325,12 @@ func observeAtomicMetadata(value AtomicProjection, fieldID string) []any { return requirement.RiskClass case "metadata.nonClaimRefs": return requirement.NonClaimRefs + case "metadata.nonClaims": + return requirement.NonClaims + case "metadata.externalNonClaimRefs": + return requirement.ExternalNonClaimRefs + case "metadata.proofBindingRefs": + return requirement.ProofBindingRefs case "metadata.lifecycle.state": return requirement.Lifecycle.State case "metadata.lifecycle.replacementRequirementIds": @@ -388,6 +397,12 @@ func metadataFieldPresent(value MetadataFields, fieldID string) bool { return value.RiskClass.Present case fieldID == "metadata.nonClaimRefs": return value.NonClaimRefs.Present + case fieldID == "metadata.nonClaims": + return value.NonClaims.Present + case fieldID == "metadata.externalNonClaimRefs": + return value.ExternalNonClaimRefs.Present + case fieldID == "metadata.proofBindingRefs": + return value.ProofBindingRefs.Present case fieldID == "metadata.lifecycle.state" || fieldID == "metadata.lifecycle.replacementRequirementIds" || fieldID == "metadata.lifecycle.evidenceRefs": return value.Lifecycle.Present case fieldID == "metadata.deferral.presence" || stringsHasPrefix(fieldID, "metadata.deferral."): @@ -408,6 +423,12 @@ func metadataFieldValue(value MetadataFields, fieldID string) (bool, any) { return value.RiskClass.Present, value.RiskClass.Value case "metadata.nonClaimRefs": return value.NonClaimRefs.Present, value.NonClaimRefs.Value + case "metadata.nonClaims": + return value.NonClaims.Present, value.NonClaims.Value + case "metadata.externalNonClaimRefs": + return value.ExternalNonClaimRefs.Present, value.ExternalNonClaimRefs.Value + case "metadata.proofBindingRefs": + return value.ProofBindingRefs.Present, value.ProofBindingRefs.Value case "metadata.lifecycle.state": return value.Lifecycle.Present, value.Lifecycle.Value.State case "metadata.lifecycle.replacementRequirementIds": diff --git a/internal/kernel/requirementsourcemodel/reference_closure.go b/internal/kernel/requirementsourcemodel/reference_closure.go index 7e4ae0f..6ee3c55 100644 --- a/internal/kernel/requirementsourcemodel/reference_closure.go +++ b/internal/kernel/requirementsourcemodel/reference_closure.go @@ -94,12 +94,12 @@ func validateReferenceClosure(definitions map[string]struct{}, vocabulary map[st } for _, definitionID := range sortedSetKeys(definitions) { if _, exists := usedDefinitions[definitionID]; !exists { - return invalid("unreferenced_definition", "nonClaimDefinitions."+definitionID) + return invalid("unreferenced_definition", identified("nonClaimDefinitions", definitionID)) } } for _, termID := range sortedSetKeys(vocabulary) { if _, exists := usedVocabulary[termID]; !exists { - return invalid("unreferenced_vocabulary", "vocabulary."+termID) + return invalid("unreferenced_vocabulary", identified("vocabulary", termID)) } } return nil diff --git a/internal/kernel/requirementsourcemodel/testdata/field-projection-manifest.v1.json b/internal/kernel/requirementsourcemodel/testdata/field-projection-manifest.v1.json index a4f0cb6..bacebb2 100644 --- a/internal/kernel/requirementsourcemodel/testdata/field-projection-manifest.v1.json +++ b/internal/kernel/requirementsourcemodel/testdata/field-projection-manifest.v1.json @@ -10,6 +10,7 @@ {"fieldId":"source.id","requiredProjections":["atomic","layout","references"]}, {"fieldId":"source.specPackagePath","requiredProjections":["atomic"]}, {"fieldId":"source.nonClaimRefs","requiredProjections":["atomic","references"]}, + {"fieldId":"source.nonClaims","requiredProjections":["atomic"]}, {"fieldId":"nonClaim.id","requiredProjections":["atomic","references"]}, {"fieldId":"nonClaim.statement","requiredProjections":["atomic"]}, {"fieldId":"vocabulary.id","requiredProjections":["atomic","references"]}, @@ -31,6 +32,9 @@ {"fieldId":"metadata.claimLevel","requiredProjections":["atomic","layout"]}, {"fieldId":"metadata.riskClass","requiredProjections":["atomic","layout"]}, {"fieldId":"metadata.nonClaimRefs","requiredProjections":["atomic","layout","references"]}, + {"fieldId":"metadata.nonClaims","requiredProjections":["atomic","layout"]}, + {"fieldId":"metadata.externalNonClaimRefs","requiredProjections":["atomic","layout"]}, + {"fieldId":"metadata.proofBindingRefs","requiredProjections":["atomic","layout"]}, {"fieldId":"metadata.lifecycle.state","requiredProjections":["atomic","layout"]}, {"fieldId":"metadata.lifecycle.replacementRequirementIds","requiredProjections":["atomic","layout","references"]}, {"fieldId":"metadata.lifecycle.evidenceRefs","requiredProjections":["atomic","layout"]}, diff --git a/internal/kernel/requirementsourcemodel/testdata/model-mutants.v1.json b/internal/kernel/requirementsourcemodel/testdata/model-mutants.v1.json index 576561b..716c548 100644 --- a/internal/kernel/requirementsourcemodel/testdata/model-mutants.v1.json +++ b/internal/kernel/requirementsourcemodel/testdata/model-mutants.v1.json @@ -2,6 +2,10 @@ "schemaVersion": 1, "kind": "proofkit.requirement-source-model-mutant-corpus", "mutants": [ + {"mutantId":"source-direct-nonclaims","mutatedFields":["source.nonClaims"],"provenFields":["source.nonClaims"],"relationIds":[],"changedProjections":["atomic"]}, + {"mutantId":"metadata-direct-nonclaims","mutatedFields":["metadata.nonClaims"],"provenFields":["metadata.nonClaims"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"metadata-external-nonclaim-refs","mutatedFields":["metadata.externalNonClaimRefs"],"provenFields":["metadata.externalNonClaimRefs"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"metadata-proof-binding-refs","mutatedFields":["metadata.proofBindingRefs"],"provenFields":["metadata.proofBindingRefs"],"relationIds":[],"changedProjections":["atomic","layout"]}, {"mutantId":"source-id","mutatedFields":["source.id"],"provenFields":["source.id"],"relationIds":[],"changedProjections":["atomic","layout","references"]}, {"mutantId":"source-package-path","mutatedFields":["source.specPackagePath"],"provenFields":["source.specPackagePath"],"relationIds":[],"changedProjections":["atomic"]}, {"mutantId":"source-nonclaim-refs","mutatedFields":["source.nonClaimRefs"],"provenFields":["source.nonClaimRefs"],"relationIds":[],"changedProjections":["atomic","references"]}, diff --git a/internal/kernel/requirementsourcemodel/text_identity_test.go b/internal/kernel/requirementsourcemodel/text_identity_test.go index 5481ea2..e6a09d0 100644 --- a/internal/kernel/requirementsourcemodel/text_identity_test.go +++ b/internal/kernel/requirementsourcemodel/text_identity_test.go @@ -3,8 +3,38 @@ package requirementsourcemodel import ( "strings" "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" ) +func TestNormalizeAdmitsTheCompleteGroupedInvariant(t *testing.T) { + const stem = "Bearer" + const completion = "syntheticfixture" + for _, part := range []string{stem, completion} { + if _, err := admit.NonEmptyText(part, "fixture"); err != nil { + t.Fatal("fixture fragment must pass the shared text owner") + } + } + if _, err := admit.NonEmptyText(stem+" "+completion, "fixture"); err == nil { + t.Fatal("fixture must reach the shared composed-text rejection") + } + draft := validDraft() + draft.Groups[0].StatementStem = stem + draft.Groups[0].Members[0].StatementCompletion = completion + _, err := Normalize(draft) + if ErrorCode(err) != "invalid_text" { + t.Fatalf("effective invariant bypassed admission: code=%q", ErrorCode(err)) + } + if strings.Contains(err.Error(), stem) || strings.Contains(err.Error(), completion) { + t.Fatal("composition diagnostic disclosed the fixture") + } + draft.Groups[0].StatementStem = "The bearer must" + model, err := Normalize(draft) + if err != nil || model.Atomic().Requirements[0].Invariant != "The bearer must "+completion { + t.Fatal("safe composition changed or was rejected") + } +} + func TestNormalizePreservesTextBySemanticRole(t *testing.T) { const completion = "accept\nrequests\twith \"quoted\" \\ values,\r\nUnicode \U0001f680 and \x00 data." const boundary = "TODO is a caller label,\nnot an implementation claim." diff --git a/internal/kernel/requirementsourcemodel/types.go b/internal/kernel/requirementsourcemodel/types.go index 17257a7..d99ee37 100644 --- a/internal/kernel/requirementsourcemodel/types.go +++ b/internal/kernel/requirementsourcemodel/types.go @@ -106,6 +106,7 @@ const ( type Draft struct { SourceID string SpecPackagePath string + SourceNonClaims []string SourceNonClaimRefs []string NonClaimDefinitions []NonClaimDefinition Vocabulary []VocabularyTerm @@ -168,13 +169,16 @@ type Member struct { } type MetadataFields struct { - OwnerID Field[string] - ClaimLevel Field[ClaimLevel] - RiskClass Field[RiskClass] - NonClaimRefs Field[[]string] - Lifecycle Field[Lifecycle] - Deferral Field[*Deferral] - UpdatePolicy Field[UpdatePolicy] + NonClaims Field[[]string] + ExternalNonClaimRefs Field[[]string] + ProofBindingRefs Field[[]string] + OwnerID Field[string] + ClaimLevel Field[ClaimLevel] + RiskClass Field[RiskClass] + NonClaimRefs Field[[]string] + Lifecycle Field[Lifecycle] + Deferral Field[*Deferral] + UpdatePolicy Field[UpdatePolicy] } type Lifecycle struct { @@ -227,6 +231,7 @@ type Model struct { type AtomicProjection struct { SourceID string SpecPackagePath string + SourceNonClaims []string SourceNonClaimRefs []string NonClaimDefinitions []NonClaimDefinition Vocabulary []VocabularyTerm @@ -235,16 +240,19 @@ type AtomicProjection struct { } type AtomicRequirement struct { - RequirementID string - Invariant string - SharedPremises []string - OwnerID string - ClaimLevel ClaimLevel - RiskClass RiskClass - NonClaimRefs []string - Lifecycle Lifecycle - Deferral *Deferral - UpdatePolicy UpdatePolicy + RequirementID string + Invariant string + SharedPremises []string + OwnerID string + ClaimLevel ClaimLevel + RiskClass RiskClass + NonClaimRefs []string + NonClaims []string + ExternalNonClaimRefs []string + ProofBindingRefs []string + Lifecycle Lifecycle + Deferral *Deferral + UpdatePolicy UpdatePolicy } type LayoutProjection struct { diff --git a/internal/kernel/requirementsourcemodel/validation.go b/internal/kernel/requirementsourcemodel/validation.go index 159456e..63865fc 100644 --- a/internal/kernel/requirementsourcemodel/validation.go +++ b/internal/kernel/requirementsourcemodel/validation.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "sort" + "strconv" "strings" "unicode/utf8" @@ -41,6 +42,10 @@ func invalid(code string, path string) error { return &ValidationError{Code: code, Path: path} } +func identified(root string, id string) string { + return root + "[" + strconv.Quote(id) + "]" +} + func canonicalID(value string, prefix string, path string) (string, error) { admitted, err := admit.RuleID(value, path) if err != nil || !strings.HasPrefix(admitted, prefix) || len(admitted) == len(prefix) { @@ -73,7 +78,7 @@ func canonicalText(value string, path string, allowEmpty bool, rejectPlaceholder func canonicalPath(value string, path string) (string, error) { admitted, err := admit.SafeRepoRelativePath(value, path) - if err != nil || admitted != value { + if err != nil || admitted != value || !utf8.ValidString(value) { return "", invalid("invalid_path", path) } return admitted, nil diff --git a/internal/tools/releasechange/record_test.go b/internal/tools/releasechange/record_test.go index 0edaef0..0aca9e4 100644 --- a/internal/tools/releasechange/record_test.go +++ b/internal/tools/releasechange/record_test.go @@ -197,8 +197,8 @@ func TestCurrentChangeRecordNamesReviewedSemanticChanges(t *testing.T) { var currentBreakingChanges = []Change{} var currentAdditions = []Change{ - {ChangeID: "proofkit.source-model.lexical-parity", Summary: "Repair the private candidate source model to preserve canonical internal UTF-8 text, including line breaks and safely escaped controls, while rejecting invalid UTF-8 and secret-shaped values. Keep unfinished-invariant checks separate from non-claim and deferral text, and admit existing stable scenario identifiers without requiring a new prefix. Public source admission, CLI contracts and execution-binding ownership are unchanged; no source-v2 cutover is included."}, - {ChangeID: "proofkit.source-codec.escape-budget", Summary: "Correct the private codec byte bound for the six-byte JSON escape of a one-byte control scalar. Native tests now exercise maximal admitted control text, the next rejected input unit, exact escaping and a complete model-format-parse round trip, alongside duplicate scenario and nondisclosure controls. This does not change published platform requirements or expose a new source format."}, + {ChangeID: "proofkit.source-model.metadata-parity", Summary: "Preserve independently authored source and requirement non-claims, external non-claim references and proof-binding paths in the private candidate source model. Keep named local references distinct from direct text and external identity; reject duplicate effective boundary statements within one scope and require binding paths for active blocking requirements. Private codec fields, projections, budgets and tests preserve member or shared-profile ownership. Public source admission and CLI contracts remain unchanged; no source-v2 cutover is included."}, + {ChangeID: "proofkit.source-model.boundary-closure", Summary: "Preserve complete dotted identifiers when mapping model errors to exact lexical owner spans, reject invalid UTF-8 paths before formatting, and admit completed grouped invariants through the shared text policy. Bound expanded projection work before repeated payload traversal while retaining item-before-text rejection precedence and exact admitted costs. Require the minimum JSON depth to cover complete canonical output and locate effective non-claim duplicates at their participating reference owner. Negative controls cover hidden payload copying, mixed budgets, Unicode paths, diagnostic identity collisions and composition. Published platform requirements are unchanged."}, } var currentMigrationSteps = []string{} @@ -221,7 +221,7 @@ func validateCurrentChangeRecord(record Record, notes string) error { func currentExpectedReleaseNotes() string { lines := []string{ - "# @research-engineering/agentic-proofkit 0.14.3", + "# @research-engineering/agentic-proofkit 0.14.4", "", "## Breaking Contract Changes", "", @@ -274,7 +274,7 @@ func currentExpectedReleaseNotes() string { "Primary npm channel:", "", "```bash", - "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.14.3", + "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.14.4", "```", "", "Pre-1.0 npm consumers must keep this dependency exact-pinned.", @@ -286,7 +286,7 @@ func currentExpectedReleaseNotes() string { "## Rollback", "", "- First follow the migration and persistent-state compatibility restrictions above; changing a package pin does not roll back repository state.", - "- Pin npm consumers to the previous admitted version 0.14.2 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.14.2`.", + "- Pin npm consumers to the previous admitted version 0.14.3 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.14.3`.", "- Treat local package artifacts as candidates until registry identity is proven.", ) return strings.Join(lines, "\n") + "\n" diff --git a/package-lock.json b/package-lock.json index e05d28e..53a48ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@research-engineering/agentic-proofkit", - "version": "0.14.3", + "version": "0.14.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@research-engineering/agentic-proofkit", - "version": "0.14.3", + "version": "0.14.4", "cpu": [ "arm64", "x64" diff --git a/package.json b/package.json index 2cc26d1..7fa268a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@research-engineering/agentic-proofkit", "description": "Reusable proof profile, report, graph, and witness-planning primitives.", - "version": "0.14.3", + "version": "0.14.4", "type": "module", "license": "MIT", "sideEffects": false, diff --git a/release/change-record.v2.json b/release/change-record.v2.json index d37b98b..c2986b3 100644 --- a/release/change-record.v2.json +++ b/release/change-record.v2.json @@ -1,17 +1,17 @@ { "schemaVersion": 2, - "previousVersion": "0.14.2", - "version": "0.14.3", + "previousVersion": "0.14.3", + "version": "0.14.4", "changeClass": "compatible", "breakingChanges": [], "additions": [ { - "changeId": "proofkit.source-model.lexical-parity", - "summary": "Repair the private candidate source model to preserve canonical internal UTF-8 text, including line breaks and safely escaped controls, while rejecting invalid UTF-8 and secret-shaped values. Keep unfinished-invariant checks separate from non-claim and deferral text, and admit existing stable scenario identifiers without requiring a new prefix. Public source admission, CLI contracts and execution-binding ownership are unchanged; no source-v2 cutover is included." + "changeId": "proofkit.source-model.metadata-parity", + "summary": "Preserve independently authored source and requirement non-claims, external non-claim references and proof-binding paths in the private candidate source model. Keep named local references distinct from direct text and external identity; reject duplicate effective boundary statements within one scope and require binding paths for active blocking requirements. Private codec fields, projections, budgets and tests preserve member or shared-profile ownership. Public source admission and CLI contracts remain unchanged; no source-v2 cutover is included." }, { - "changeId": "proofkit.source-codec.escape-budget", - "summary": "Correct the private codec byte bound for the six-byte JSON escape of a one-byte control scalar. Native tests now exercise maximal admitted control text, the next rejected input unit, exact escaping and a complete model-format-parse round trip, alongside duplicate scenario and nondisclosure controls. This does not change published platform requirements or expose a new source format." + "changeId": "proofkit.source-model.boundary-closure", + "summary": "Preserve complete dotted identifiers when mapping model errors to exact lexical owner spans, reject invalid UTF-8 paths before formatting, and admit completed grouped invariants through the shared text policy. Bound expanded projection work before repeated payload traversal while retaining item-before-text rejection precedence and exact admitted costs. Require the minimum JSON depth to cover complete canonical output and locate effective non-claim duplicates at their participating reference owner. Negative controls cover hidden payload copying, mixed budgets, Unicode paths, diagnostic identity collisions and composition. Published platform requirements are unchanged." } ], "migration": {