From e7f0e9421d3c0a80b15896b2214aadfa64d6cd27 Mon Sep 17 00:00:00 2001 From: iperev Date: Mon, 14 Sep 2026 19:11:23 +0200 Subject: [PATCH 1/3] test(source): guard compatibility and resource boundaries --- ADOPTION.md | 12 ++ internal/app/source_compat_domain_test.go | 108 ++++++++++++ internal/app/source_compat_fields_test.go | 181 ++++++++++++++++++++ internal/app/source_compat_fixture_test.go | 141 +++++++++++++++ internal/app/source_compat_routes_test.go | 110 ++++++++++++ internal/tools/releasechange/record_test.go | 6 +- package-lock.json | 4 +- package.json | 2 +- release/change-record.v2.json | 8 +- 9 files changed, 562 insertions(+), 10 deletions(-) create mode 100644 internal/app/source_compat_domain_test.go create mode 100644 internal/app/source_compat_fields_test.go create mode 100644 internal/app/source_compat_fixture_test.go create mode 100644 internal/app/source_compat_routes_test.go diff --git a/ADOPTION.md b/ADOPTION.md index be9ea8be..a66d3ec1 100644 --- a/ADOPTION.md +++ b/ADOPTION.md @@ -293,6 +293,18 @@ human context in `overview.md` plus machine-admissible `requirements.v1.json` records. The overview explains context; it does not create uncited durable truth. +The public source contract still requires `schemaVersion: 1` and the matching +`specPackagePath/overview.md` and `specPackagePath/requirements.v1.json` paths. +The internal grouped v2 model and codec are not an alternative public reader. +Changing a version number or filename does not migrate a source or its bindings. + +Canonical field correspondence and accepted input domains are separate checks. +The current public byte limit does not imply that a source fits the private +model's member and expanded-projection budgets. A future source transition must +declare its versioned limits and qualify every affected consumer; it must not +silently narrow current admission or infer scenario bodies from binding IDs. +Keep the public v1 source active until that transition is explicitly admitted. + Proof bindings are verification-route contracts. They answer which scenario, witness, command, environment class, and receipt policy can falsify or support a requirement. Native tests and tools own executable verification procedures diff --git a/internal/app/source_compat_domain_test.go b/internal/app/source_compat_domain_test.go new file mode 100644 index 00000000..0befe6f2 --- /dev/null +++ b/internal/app/source_compat_domain_test.go @@ -0,0 +1,108 @@ +package app + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" + + legacy "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + codec "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcecodec" + model "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +func TestSourceCompatibilityResourceDomainsAreNotInterchangeable(t *testing.T) { + for _, item := range []struct { + count int + code string + }{ + {4096, ""}, {16384, "expanded_item_budget_exceeded"}, {16385, "member_budget_exceeded"}, + } { + t.Run(fmt.Sprint(item.count), func(t *testing.T) { + data := sourceCompatJSON(t, legacy.SourceValue(sourceCompatSource(item.count))) + if len(data) >= maxInputBytes { + t.Fatal("source no longer fits the public byte limit; re-evaluate the domain witness") + } + output := runCLI(t, []string{"requirement-source-admission", "--input", "-"}, string(data)) + report := sourceCompatDecode(t, output).(map[string]any) + if report["reportKind"] != "proofkit.requirement-source-admission" || report["state"] != "passed" || report["reportId"] != "source.compatibility" { + t.Fatal("wrong public admission result") + } + source := sourceCompatAdmit(t, sourceCompatDecode(t, data)) + for range 2 { + candidate, err := model.Normalize(sourceCompatDraft(source)) + if model.ErrorCode(err) != item.code { + t.Fatalf("resource-domain decision changed: got %v, want %s", err, item.code) + } + if item.code == "" && len(candidate.Atomic().Requirements) != item.count { + t.Fatal("candidate dropped an accepted requirement") + } + } + sourceCompatRoundTrip(t, sourceCompatSource(1)) + }) + } +} + +func TestSourceCompatibilityPublicIdentityAndPathsStayV1(t *testing.T) { + source := sourceCompatSource(1) + for _, field := range []string{"overviewPath", "requirementsPath"} { + t.Run(field, func(t *testing.T) { + input := legacy.SourceValue(source) + input[field] = "docs/specs/other/" + map[string]string{"overviewPath": "overview.md", "requirementsPath": "requirements.v2.json"}[field] + var stdout, stderr bytes.Buffer + exit := Run(t.Context(), []string{"requirement-source-admission", "--input", "-"}, bytes.NewReader(sourceCompatJSON(t, input)), &stdout, &stderr) + if exit != 1 || stderr.Len() != 0 { + t.Fatalf("path contradiction exit=%d stderr=%q", exit, stderr.String()) + } + report := sourceCompatDecode(t, stdout.Bytes()).(map[string]any) + if report["state"] != "failed" || report["reportKind"] != "proofkit.requirement-source-admission" { + t.Fatal("path contradiction did not produce the expected failed report") + } + }) + } + v1 := sourceCompatJSON(t, legacy.SourceValue(source)) + if _, err := codec.Parse(v1); err == nil { + t.Fatal("private v2 reader accepted public v1 bytes") + } + candidate := sourceCompatRoundTrip(t, source) + v2, err := codec.Format(candidate) + if err != nil { + t.Fatal(err) + } + for _, input := range []any{sourceCompatDecode(t, v2), legacy.SourceValue(source)} { + input.(map[string]any)["schemaVersion"] = json.Number("2") + var stdout, stderr bytes.Buffer + exit := Run(t.Context(), []string{"requirement-source-admission", "--input", "-"}, bytes.NewReader(sourceCompatJSON(t, input)), &stdout, &stderr) + if exit != 1 || stdout.Len() != 0 || stderr.Len() == 0 { + t.Fatal("private/version-substituted input crossed the public v1 boundary") + } + } + wrongVersion := sourceCompatDecode(t, v2).(map[string]any) + wrongVersion["schemaVersion"] = json.Number("1") + if _, err := codec.Parse(sourceCompatJSON(t, wrongVersion)); err == nil { + t.Fatal("private grammar accepted the old identity") + } + runCLI(t, []string{"requirement-source-admission", "--input", "-"}, string(v1)) +} + +func TestSourceCompatibilityCanonicalNullIsNotInventedPresence(t *testing.T) { + input := legacy.SourceValue(sourceCompatSource(1)) + row := input["requirements"].([]any)[0].(map[string]any) + without := sourceCompatAdmit(t, input) + row["deferral"] = nil + withNull := sourceCompatAdmit(t, input) + if !reflect.DeepEqual(without, withNull) { + t.Fatal("canonical legacy deferral presence changed") + } + candidate := sourceCompatRoundTrip(t, withNull) + fields := candidate.Layout().Groups[0].Members[0].Fields + if !fields.Deferral.Present || fields.Deferral.Value != nil || !fields.NonClaimRefs.Present || len(fields.NonClaimRefs.Value) != 0 { + t.Fatal("explicit candidate null/empty mapping was not retained") + } + row["nonClaims"] = nil + if _, err := legacy.Evaluate(input); err == nil || !strings.Contains(err.Error(), "array") { + t.Fatal("null collection incorrectly treated as empty") + } +} diff --git a/internal/app/source_compat_fields_test.go b/internal/app/source_compat_fields_test.go new file mode 100644 index 00000000..d00c1446 --- /dev/null +++ b/internal/app/source_compat_fields_test.go @@ -0,0 +1,181 @@ +package app + +import ( + "reflect" + "slices" + "strings" + "testing" + + legacy "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + model "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +func TestSourceCompatibilityCanonicalFieldInventory(t *testing.T) { + for _, item := range []struct { + value any + fields string + }{ + {legacy.Source{}, "NonClaims OverviewPath Requirements RequirementsPath SourceID SpecPackagePath"}, + {legacy.Requirement{}, "ClaimLevel Deferral Invariant Lifecycle NonClaimRefs NonClaims OwnerID ProofBindingRefs RequirementID RiskClass UpdatePolicy"}, + {legacy.Lifecycle{}, "EvidenceRefs ReplacementRequirementIDs State"}, + {legacy.Deferral{}, "EvidenceRefs ExpiryRef MergePolicy OwnerID ReviewCondition RiskAcceptedBy"}, + {legacy.UpdatePolicy{}, "RequiresImpactDeclaration RequiresProofBindingReview ReviewOwnerID"}, + } { + value := reflect.TypeOf(item.value) + actual := []string{} + for i := range value.NumField() { + actual = append(actual, value.Field(i).Name) + } + slices.Sort(actual) + if !slices.Equal(actual, strings.Fields(item.fields)) { + t.Fatalf("%s fields changed; revise the complete mapping: %v", value.Name(), actual) + } + } + source := sourceCompatSource(1) + source.Requirements[0].ClaimLevel = "deferred" + source.Requirements[0].Deferral = &legacy.Deferral{OwnerID: "owner.deferral", RiskAcceptedBy: "owner.risk", ReviewCondition: "Review the input.", ExpiryRef: "expiry.ref", MergePolicy: "merge.policy", EvidenceRefs: []string{"evidence/deferred.json"}} + wire := legacy.SourceValue(sourceCompatAdmit(t, legacy.SourceValue(source))) + requirement := wire["requirements"].([]any)[0].(map[string]any) + count := 0 + for _, row := range []struct { + value map[string]any + fields string + }{ + {wire, "nonClaims overviewPath requirements requirementsPath schemaVersion sourceId specPackagePath"}, + {requirement, "claimLevel deferral invariant lifecycle nonClaimRefs nonClaims ownerId proofBindingRefs requirementId riskClass updatePolicy"}, + {requirement["lifecycle"].(map[string]any), "evidenceRefs replacementRequirementIds state"}, + {requirement["deferral"].(map[string]any), "evidenceRefs expiryRef mergePolicy ownerId reviewCondition riskAcceptedBy"}, + {requirement["updatePolicy"].(map[string]any), "requiresImpactDeclaration requiresProofBindingReview reviewOwnerId"}, + } { + keys := []string{} + for key := range row.value { + keys = append(keys, key) + } + slices.Sort(keys) + if !slices.Equal(keys, strings.Fields(row.fields)) { + t.Fatalf("canonical wire fields changed: %v", keys) + } + count += len(keys) + } + if count != 30 { + t.Fatalf("whole-source field inventory=%d, want30 including parent records", count) + } +} + +func TestSourceCompatibilityAllFieldsAndVariants(t *testing.T) { + for _, lifecycle := range []string{"active", "deprecated", "removed", "superseded"} { + for _, claim := range []string{"advisory", "blocking", "deferred"} { + if lifecycle != "active" && claim == "blocking" { + continue // This combination is independently forbidden by both owners. + } + for _, risk := range []string{"critical", "high", "low", "medium"} { + t.Run(lifecycle+"/"+claim+"/"+risk, func(t *testing.T) { + source := sourceCompatSource(2) + r := &source.Requirements[0] + r.ClaimLevel, r.RiskClass, r.Lifecycle.State = claim, risk, lifecycle + r.Invariant = "The value preserves\nUnicode \U0001f9ed and literal < & > delimiters." + r.NonClaimRefs = []string{"external.alpha", "external.beta"} + r.NonClaims = []string{"A direct denial remains independent.", "No execution is inferred."} + r.ProofBindingRefs = []string{"proofkit/alpha.json", "proofkit/beta.json"} + r.UpdatePolicy.RequiresImpactDeclaration, r.UpdatePolicy.RequiresProofBindingReview = true, true + if lifecycle != "active" { + r.Lifecycle.EvidenceRefs = []string{"evidence/lifecycle.json"} + } + if lifecycle == "superseded" { + r.Lifecycle.ReplacementRequirementIDs = []string{source.Requirements[1].RequirementID} + } + if claim == "deferred" { + r.Deferral = &legacy.Deferral{OwnerID: "owner.deferral", RiskAcceptedBy: "owner.risk", ReviewCondition: "Review when the declared input changes.", + ExpiryRef: "expiry.condition", MergePolicy: "policy.merge", EvidenceRefs: []string{"evidence/deferral.json"}} + } + sourceCompatRoundTrip(t, source) + }) + } + } + } + for _, impact := range []bool{false, true} { + for _, binding := range []bool{false, true} { + source := sourceCompatSource(1) + source.Requirements[0].UpdatePolicy.RequiresImpactDeclaration = impact + source.Requirements[0].UpdatePolicy.RequiresProofBindingReview = binding + candidate := sourceCompatRoundTrip(t, source) + got := candidate.Atomic().Requirements[0].UpdatePolicy + if got.RequiresImpactDeclaration != impact || got.RequiresProofBindingReview != binding { + t.Fatal("independent update-policy operands collapsed") + } + } + } + for _, count := range []int{0, 1, 4096, 4097} { + sourceCompatRoundTrip(t, sourceCompatSource(count)) + } +} + +func TestSourceCompatibilityRejectsPairedLifecycleContradictions(t *testing.T) { + tests := []struct { + name string + mutate func(*legacy.Source) + }{ + {"missing_proof_binding", func(s *legacy.Source) { + r := &s.Requirements[0] + r.ClaimLevel = "blocking" + r.UpdatePolicy.RequiresImpactDeclaration = true + r.UpdatePolicy.RequiresProofBindingReview = true + }}, + {"impact_review_required", func(s *legacy.Source) { + r := &s.Requirements[0] + r.ClaimLevel = "blocking" + r.ProofBindingRefs = []string{"proofkit/proof.json"} + r.UpdatePolicy.RequiresProofBindingReview = true + }}, + {"proof_binding_review_required", func(s *legacy.Source) { + r := &s.Requirements[0] + r.ClaimLevel = "blocking" + r.ProofBindingRefs = []string{"proofkit/proof.json"} + r.UpdatePolicy.RequiresImpactDeclaration = true + }}, + {"missing_deferral", func(s *legacy.Source) { s.Requirements[0].ClaimLevel = "deferred" }}, + {"unexpected_deferral", func(s *legacy.Source) { + s.Requirements[0].Deferral = &legacy.Deferral{OwnerID: "owner.deferral", RiskAcceptedBy: "owner.risk", ReviewCondition: "Review the input.", ExpiryRef: "expiry.ref", MergePolicy: "merge.policy", EvidenceRefs: []string{"evidence/deferred.json"}} + }}, + {"missing_lifecycle_evidence", func(s *legacy.Source) { s.Requirements[0].Lifecycle.State = "deprecated" }}, + {"unexpected_replacement", func(s *legacy.Source) { + s.Requirements[0].Lifecycle.ReplacementRequirementIDs = []string{s.Requirements[1].RequirementID} + }}, + {"missing_replacement", func(s *legacy.Source) { + s.Requirements[0].Lifecycle = legacy.Lifecycle{State: "superseded", EvidenceRefs: []string{"evidence/history.json"}} + }}, + {"self_replacement", func(s *legacy.Source) { + s.Requirements[0].Lifecycle = legacy.Lifecycle{State: "superseded", EvidenceRefs: []string{"evidence/history.json"}, ReplacementRequirementIDs: []string{s.Requirements[0].RequirementID}} + }}, + {"dangling_replacement", func(s *legacy.Source) { + s.Requirements[0].Lifecycle = legacy.Lifecycle{State: "superseded", EvidenceRefs: []string{"evidence/history.json"}, ReplacementRequirementIDs: []string{"REQ-MISSING"}} + }}, + {"inactive_replacement", func(s *legacy.Source) { + s.Requirements[0].Lifecycle = legacy.Lifecycle{State: "superseded", EvidenceRefs: []string{"evidence/history.json"}, ReplacementRequirementIDs: []string{s.Requirements[1].RequirementID}} + s.Requirements[1].Lifecycle = legacy.Lifecycle{State: "removed", EvidenceRefs: []string{"evidence/removal.json"}} + }}, + {"nonactive_blocking_requirement", func(s *legacy.Source) { + r := &s.Requirements[0] + r.ClaimLevel = "blocking" + r.ProofBindingRefs = []string{"proofkit/proof.json"} + r.UpdatePolicy.RequiresImpactDeclaration = true + r.UpdatePolicy.RequiresProofBindingReview = true + r.Lifecycle = legacy.Lifecycle{State: "removed", EvidenceRefs: []string{"evidence/removal.json"}} + }}, + } + for _, item := range tests { + t.Run(item.name, func(t *testing.T) { + source := sourceCompatSource(2) + item.mutate(&source) + raw := sourceCompatDecode(t, sourceCompatJSON(t, legacy.SourceValue(source))) + result, err := legacy.Evaluate(raw) + if err != nil || result.ExitCode != 1 || result.Report.State != "failed" { + t.Fatalf("legacy semantic contradiction was not rejected: %v", err) + } + if _, err := model.Normalize(sourceCompatDraft(result.Source)); model.ErrorCode(err) != item.name { + t.Fatalf("candidate did not reject the intended contradiction: %v", err) + } + sourceCompatRoundTrip(t, sourceCompatSource(2)) + }) + } +} diff --git a/internal/app/source_compat_fixture_test.go b/internal/app/source_compat_fixture_test.go new file mode 100644 index 00000000..d4f189c3 --- /dev/null +++ b/internal/app/source_compat_fixture_test.go @@ -0,0 +1,141 @@ +package app + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "testing" + + legacy "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + codec "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcecodec" + model "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +func sourceCompatJSON(t testing.TB, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return data +} + +func sourceCompatDecode(t testing.TB, data []byte) any { + t.Helper() + value, err := admission.DecodeJSON(bytes.NewReader(data), maxInputBytes) + if err != nil { + t.Fatal(err) + } + return value +} + +func sourceCompatAdmit(t testing.TB, raw any) legacy.Source { + t.Helper() + value := sourceCompatDecode(t, sourceCompatJSON(t, raw)) + result, err := legacy.Evaluate(value) + if err != nil || result.ExitCode != 0 { + t.Fatalf("legacy source premise: err=%v failures=%v", err, result.Failures) + } + return result.Source +} + +func sourceCompatSource(count int) legacy.Source { + source := legacy.Source{SourceID: "source.compatibility", SpecPackagePath: "docs/specs/compatibility", + OverviewPath: "docs/specs/compatibility/overview.md", RequirementsPath: "docs/specs/compatibility/requirements.v1.json", + NonClaims: []string{"Source compatibility does not prove execution."}, Requirements: []legacy.Requirement{}} + for i := range count { + source.Requirements = append(source.Requirements, legacy.Requirement{ + RequirementID: fmt.Sprintf("REQ-COMPAT-%05d", i), OwnerID: "owner.requirement", + Invariant: "The operation preserves its independently declared input.", ClaimLevel: "advisory", RiskClass: "low", + Lifecycle: legacy.Lifecycle{State: "active"}, NonClaims: []string{"No native witness is asserted."}, + UpdatePolicy: legacy.UpdatePolicy{ReviewOwnerID: "owner.review"}, + }) + } + return source +} + +// This test-only mapping has no public converter or scenario-body authority. +func sourceCompatDraft(source legacy.Source) model.Draft { + draft := model.Draft{SourceID: source.SourceID, SpecPackagePath: source.SpecPackagePath, SourceNonClaims: source.NonClaims} + for i, r := range source.Requirements { + if i%model.DefaultLimits().MaxMembersPerGroup == 0 { + draft.Groups = append(draft.Groups, model.Group{GroupID: fmt.Sprintf("RGRP-COMPAT-%05d", len(draft.Groups))}) + } + var deferral *model.Deferral + if r.Deferral != nil { + d := r.Deferral + deferral = &model.Deferral{OwnerID: d.OwnerID, RiskAcceptedBy: d.RiskAcceptedBy, ReviewCondition: d.ReviewCondition, + ExpiryRef: d.ExpiryRef, MergePolicy: d.MergePolicy, EvidenceRefs: d.EvidenceRefs} + } + member := model.Member{RequirementID: r.RequirementID, StatementCompletion: r.Invariant, Fields: model.MetadataFields{ + OwnerID: model.Own(r.OwnerID), ClaimLevel: model.Own(model.ClaimLevel(r.ClaimLevel)), RiskClass: model.Own(model.RiskClass(r.RiskClass)), + NonClaims: model.Own(r.NonClaims), ExternalNonClaimRefs: model.Own(r.NonClaimRefs), NonClaimRefs: model.Own([]string{}), ProofBindingRefs: model.Own(r.ProofBindingRefs), + Lifecycle: model.Own(model.Lifecycle{State: model.LifecycleState(r.Lifecycle.State), EvidenceRefs: r.Lifecycle.EvidenceRefs, ReplacementRequirementIDs: r.Lifecycle.ReplacementRequirementIDs}), + Deferral: model.Own(deferral), UpdatePolicy: model.Own(model.UpdatePolicy{ReviewOwnerID: r.UpdatePolicy.ReviewOwnerID, + RequiresImpactDeclaration: r.UpdatePolicy.RequiresImpactDeclaration, RequiresProofBindingReview: r.UpdatePolicy.RequiresProofBindingReview}), + }} + last := len(draft.Groups) - 1 + draft.Groups[last].Members = append(draft.Groups[last].Members, member) + } + return draft +} + +func sourceCompatLegacy(candidate model.Model) (legacy.Source, error) { + a := candidate.Atomic() + if len(a.SourceNonClaimRefs)+len(a.NonClaimDefinitions)+len(a.Vocabulary)+len(a.Scenarios)+len(candidate.References().Derivations) != 0 { + return legacy.Source{}, fmt.Errorf("candidate has non-legacy semantics") + } + result := legacy.Source{SourceID: a.SourceID, SpecPackagePath: a.SpecPackagePath, + OverviewPath: a.SpecPackagePath + "/overview.md", RequirementsPath: a.SpecPackagePath + "/requirements.v1.json", + NonClaims: a.SourceNonClaims, Requirements: []legacy.Requirement{}} + for _, r := range a.Requirements { + if len(r.SharedPremises)+len(r.NonClaimRefs) != 0 { + return legacy.Source{}, fmt.Errorf("candidate has non-legacy requirement semantics") + } + var deferral *legacy.Deferral + if r.Deferral != nil { + d := r.Deferral + deferral = &legacy.Deferral{OwnerID: d.OwnerID, RiskAcceptedBy: d.RiskAcceptedBy, ReviewCondition: d.ReviewCondition, + ExpiryRef: d.ExpiryRef, MergePolicy: d.MergePolicy, EvidenceRefs: d.EvidenceRefs} + } + result.Requirements = append(result.Requirements, legacy.Requirement{ + RequirementID: r.RequirementID, Invariant: r.Invariant, OwnerID: r.OwnerID, ClaimLevel: string(r.ClaimLevel), RiskClass: string(r.RiskClass), + NonClaimRefs: r.ExternalNonClaimRefs, NonClaims: r.NonClaims, ProofBindingRefs: r.ProofBindingRefs, + Lifecycle: legacy.Lifecycle{State: string(r.Lifecycle.State), EvidenceRefs: r.Lifecycle.EvidenceRefs, ReplacementRequirementIDs: r.Lifecycle.ReplacementRequirementIDs}, + Deferral: deferral, UpdatePolicy: legacy.UpdatePolicy{ReviewOwnerID: r.UpdatePolicy.ReviewOwnerID, + RequiresImpactDeclaration: r.UpdatePolicy.RequiresImpactDeclaration, RequiresProofBindingReview: r.UpdatePolicy.RequiresProofBindingReview}, + }) + } + admitted, err := legacy.Evaluate(legacy.SourceValue(result)) + if err != nil || admitted.ExitCode != 0 { + return legacy.Source{}, fmt.Errorf("reconstructed source rejected: %v %v", err, admitted.Failures) + } + return admitted.Source, nil +} + +func sourceCompatRoundTrip(t testing.TB, source legacy.Source) model.Model { + t.Helper() + admitted := sourceCompatAdmit(t, legacy.SourceValue(source)) + candidate, err := model.Normalize(sourceCompatDraft(admitted)) + if err != nil { + t.Fatal(err) + } + encoded, err := codec.Format(candidate) + if err != nil { + t.Fatal(err) + } + reparsed, err := codec.Parse(encoded) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(candidate.Atomic(), reparsed.Model.Atomic()) || !reflect.DeepEqual(candidate.Layout(), reparsed.Model.Layout()) || !reflect.DeepEqual(candidate.References(), reparsed.Model.References()) { + t.Fatal("private codec lost a complete owner projection") + } + restored, err := sourceCompatLegacy(reparsed.Model) + if err != nil || !reflect.DeepEqual(restored, admitted) { + t.Fatalf("whole canonical source correspondence failed: %v", err) + } + return reparsed.Model +} diff --git a/internal/app/source_compat_routes_test.go b/internal/app/source_compat_routes_test.go new file mode 100644 index 00000000..236d7df6 --- /dev/null +++ b/internal/app/source_compat_routes_test.go @@ -0,0 +1,110 @@ +package app + +import ( + "bytes" + "reflect" + "slices" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/command/adoptionmaterialization" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/projectfixture" +) + +func TestSourceCompatibilityQualifiedBindingRowsSurviveCLI(t *testing.T) { + fixture := projectfixture.New(t) + result, err := requirementbinding.Build(fixture.Project["proofBinding"]) + if err != nil || result.Record.State != "passed" { + t.Fatal("binding fixture was not admitted", err) + } + input := result.Input + for i := range input.Bindings { + input.Bindings[i].ScenarioID = "scenario.shared" + input.Bindings[i].WitnessID = "witness.shared" + } + otherScenario, otherWitness := input.Bindings[0], input.Bindings[0] + otherScenario.ScenarioID = "scenario.other" + otherWitness.WitnessID = "witness.other" + input.Bindings = append(input.Bindings, otherScenario, otherWitness) + slices.SortFunc(input.Bindings, func(a, b requirementbinding.Binding) int { + for _, pair := range [][2]string{{a.RequirementID, b.RequirementID}, {a.ScenarioID, b.ScenarioID}, {a.WitnessID, b.WitnessID}} { + if order := strings.Compare(pair[0], pair[1]); order != 0 { + return order + } + } + return 0 + }) + graphBytes := runCLI(t, []string{"evidence-graph", "--input", "-"}, string(sourceCompatJSON(t, requirementbinding.InputValue(input)))) + graph := sourceCompatDecode(t, graphBytes).(map[string]any) + if graph["graphKind"] != "proofkit.requirement-evidence-graph" || graph["bindingId"] != "shared.identity" { + t.Fatal("wrong graph identity") + } + actual := map[[3]string]map[string]any{} + sourcePaths := map[string]string{} + for _, raw := range graph["requirements"].([]any) { + requirement := raw.(map[string]any) + id := requirement["requirementId"].(string) + sourcePaths[id] = requirement["specPath"].(string) + for _, raw := range requirement["scenarios"].([]any) { + row := raw.(map[string]any) + key := [3]string{id, row["scenarioId"].(string), row["witnessId"].(string)} + if _, duplicate := actual[key]; duplicate { + t.Fatal("duplicate qualified graph tuple") + } + actual[key] = row + } + } + if !reflect.DeepEqual(sourcePaths, map[string]string{"REQ-WIRE-001": "docs/specs/a/requirements.v1.json", "REQ-WIRE-002": "docs/specs/z/requirements.v1.json", "REQ-WIRE-003": "docs/specs/z/requirements.v1.json"}) { + t.Fatal("source-qualified requirement paths changed") + } + if len(actual) != 5 { + t.Fatalf("qualified rows=%d, want5", len(actual)) + } + for _, expected := range input.Bindings { + row := actual[[3]string{expected.RequirementID, expected.ScenarioID, expected.WitnessID}] + selectors := []any{} + for _, selector := range expected.WitnessSelectors { + selectors = append(selectors, map[string]any{"command": selector.Command, "selector": selector.Selector}) + } + want := map[string]any{"scenarioId": expected.ScenarioID, "witnessId": expected.WitnessID, + "witnessPath": expected.WitnessPath, "witnessKind": expected.WitnessKind, "witnessSelectors": selectors, + "commandIds": sourceCompatDecode(t, sourceCompatJSON(t, expected.CommandIDs)), "environmentClasses": sourceCompatDecode(t, sourceCompatJSON(t, expected.EnvironmentClasses))} + if !reflect.DeepEqual(row, want) { + t.Fatalf("lost qualified route operands for %s/%s/%s", expected.RequirementID, expected.ScenarioID, expected.WitnessID) + } + } + input.Bindings = append(input.Bindings, input.Bindings[0]) + var stdout, stderr bytes.Buffer + exit := Run(t.Context(), []string{"evidence-graph", "--input", "-"}, bytes.NewReader(sourceCompatJSON(t, requirementbinding.InputValue(input))), &stdout, &stderr) + if exit != 1 || stdout.Len() != 0 || stderr.Len() == 0 { + t.Fatal("complete duplicate binding tuple was not rejected") + } +} + +func TestSourceCompatibilityRejectsKnownButWrongSourcePath(t *testing.T) { + fixture := projectfixture.New(t) + project, err := adoptionmaterialization.AdmitProject(fixture.Project) + if err != nil { + t.Fatal("project premise failed", err) + } + for _, substitute := range []string{"docs/specs/z/requirements.v1.json", "docs/specs/missing/requirements.v1.json"} { + value, err := project.JSONValue() + if err != nil { + t.Fatal(err) + } + rows := value["proofBinding"].(map[string]any)["requirements"].([]any) + rows[0].(map[string]any)["specPath"] = substitute + _, err = adoptionmaterialization.AdmitProject(value) + if err == nil || !strings.Contains(err.Error(), "binding requirement projection does not match its source owner") { + t.Fatalf("wrong source path did not fail the owner relation before digest comparison: %v", err) + } + } + value, err := project.JSONValue() + if err != nil { + t.Fatal(err) + } + if _, err := adoptionmaterialization.AdmitProject(value); err != nil { + t.Fatal("original project no longer re-admits", err) + } +} diff --git a/internal/tools/releasechange/record_test.go b/internal/tools/releasechange/record_test.go index f11f352e..74a5b83e 100644 --- a/internal/tools/releasechange/record_test.go +++ b/internal/tools/releasechange/record_test.go @@ -220,7 +220,7 @@ func validateCurrentChangeRecord(record Record, notes string) error { func currentExpectedReleaseNotes() string { lines := []string{ - "# @research-engineering/agentic-proofkit 0.14.17", + "# @research-engineering/agentic-proofkit 0.14.18", "", "## Breaking Contract Changes", "", @@ -273,7 +273,7 @@ func currentExpectedReleaseNotes() string { "Primary npm channel:", "", "```bash", - "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.14.17", + "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.14.18", "```", "", "Pre-1.0 npm consumers must keep this dependency exact-pinned.", @@ -285,7 +285,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.16 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.14.16`.", + "- Pin npm consumers to the previous admitted version 0.14.17 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.14.17`.", "- 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 35b1b1a3..280f2b10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@research-engineering/agentic-proofkit", - "version": "0.14.17", + "version": "0.14.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@research-engineering/agentic-proofkit", - "version": "0.14.17", + "version": "0.14.18", "cpu": [ "arm64", "x64" diff --git a/package.json b/package.json index 97e02a1a..a82cd27a 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.17", + "version": "0.14.18", "type": "module", "license": "MIT", "sideEffects": false, diff --git a/release/change-record.v2.json b/release/change-record.v2.json index 8659292a..4e65e13d 100644 --- a/release/change-record.v2.json +++ b/release/change-record.v2.json @@ -1,13 +1,13 @@ { "schemaVersion": 2, - "previousVersion": "0.14.16", - "version": "0.14.17", + "previousVersion": "0.14.17", + "version": "0.14.18", "changeClass": "compatible", "breakingChanges": [], "additions": [ { - "changeId": "proofkit.guidance.coverage-currentness", - "summary": "Connect lazy CLI input recipes for declaration coverage and receipt currentness to the native traceability cookbook. Preserve composer-to-view handoff, original receipt subjects, scope decisions and separate execution, currentness and trust predicates. Add causal recipe tests and refresh source-checkout provenance digests without changing runtime admission, ordinary guidance outputs, machine schema semantics, dependencies or supported platforms." + "changeId": "proofkit.source.compatibility-boundaries", + "summary": "Make common-field source correspondence, public v1 identity and paths, qualified binding projection and private-model resource-domain boundaries permanent cross-owner regression checks. Clarify that the internal grouped codec is not a public source transition. Runtime admission, CLI contracts, source formats, resource limits, dependencies and supported platforms remain unchanged." } ], "migration": { From 9b6bdd92ba0ca46638925378bceca4805034e539 Mon Sep 17 00:00:00 2001 From: iperev Date: Mon, 14 Sep 2026 19:15:13 +0200 Subject: [PATCH 2/3] test(release): align source compatibility change oracle --- internal/tools/releasechange/record_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tools/releasechange/record_test.go b/internal/tools/releasechange/record_test.go index 74a5b83e..7a262026 100644 --- a/internal/tools/releasechange/record_test.go +++ b/internal/tools/releasechange/record_test.go @@ -197,7 +197,7 @@ func TestCurrentChangeRecordNamesReviewedSemanticChanges(t *testing.T) { var currentBreakingChanges = []Change{} var currentAdditions = []Change{ - {ChangeID: "proofkit.guidance.coverage-currentness", Summary: "Connect lazy CLI input recipes for declaration coverage and receipt currentness to the native traceability cookbook. Preserve composer-to-view handoff, original receipt subjects, scope decisions and separate execution, currentness and trust predicates. Add causal recipe tests and refresh source-checkout provenance digests without changing runtime admission, ordinary guidance outputs, machine schema semantics, dependencies or supported platforms."}, + {ChangeID: "proofkit.source.compatibility-boundaries", Summary: "Make common-field source correspondence, public v1 identity and paths, qualified binding projection and private-model resource-domain boundaries permanent cross-owner regression checks. Clarify that the internal grouped codec is not a public source transition. Runtime admission, CLI contracts, source formats, resource limits, dependencies and supported platforms remain unchanged."}, } var currentMigrationSteps = []string{} From e2ae1fbdcf50b59b3cdc9f4feae7e521ba57a30b Mon Sep 17 00:00:00 2001 From: iperev Date: Mon, 14 Sep 2026 19:38:52 +0200 Subject: [PATCH 3/3] test(source): isolate path and route payload controls --- internal/app/source_compat_domain_test.go | 16 +++++++++++++--- internal/app/source_compat_routes_test.go | 6 ++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/internal/app/source_compat_domain_test.go b/internal/app/source_compat_domain_test.go index 0befe6f2..4ba5f99e 100644 --- a/internal/app/source_compat_domain_test.go +++ b/internal/app/source_compat_domain_test.go @@ -47,10 +47,17 @@ func TestSourceCompatibilityResourceDomainsAreNotInterchangeable(t *testing.T) { func TestSourceCompatibilityPublicIdentityAndPathsStayV1(t *testing.T) { source := sourceCompatSource(1) - for _, field := range []string{"overviewPath", "requirementsPath"} { - t.Run(field, func(t *testing.T) { + for _, item := range []struct{ name, field, path string }{ + {"overview/directory", "overviewPath", "docs/specs/other/overview.md"}, + {"overview/basename", "overviewPath", source.SpecPackagePath + "/summary.md"}, + {"overview/both", "overviewPath", "docs/specs/other/summary.md"}, + {"requirements/directory", "requirementsPath", "docs/specs/other/requirements.v1.json"}, + {"requirements/basename", "requirementsPath", source.SpecPackagePath + "/requirements.v2.json"}, + {"requirements/both", "requirementsPath", "docs/specs/other/requirements.v2.json"}, + } { + t.Run(item.name, func(t *testing.T) { input := legacy.SourceValue(source) - input[field] = "docs/specs/other/" + map[string]string{"overviewPath": "overview.md", "requirementsPath": "requirements.v2.json"}[field] + input[item.field] = item.path var stdout, stderr bytes.Buffer exit := Run(t.Context(), []string{"requirement-source-admission", "--input", "-"}, bytes.NewReader(sourceCompatJSON(t, input)), &stdout, &stderr) if exit != 1 || stderr.Len() != 0 { @@ -60,6 +67,9 @@ func TestSourceCompatibilityPublicIdentityAndPathsStayV1(t *testing.T) { if report["state"] != "failed" || report["reportKind"] != "proofkit.requirement-source-admission" { t.Fatal("path contradiction did not produce the expected failed report") } + if report["summary"].(map[string]any)["failureCount"] != json.Number("1") { + t.Fatal("path case triggered an unrelated additional failure") + } }) } v1 := sourceCompatJSON(t, legacy.SourceValue(source)) diff --git a/internal/app/source_compat_routes_test.go b/internal/app/source_compat_routes_test.go index 236d7df6..6c947dfa 100644 --- a/internal/app/source_compat_routes_test.go +++ b/internal/app/source_compat_routes_test.go @@ -23,9 +23,15 @@ func TestSourceCompatibilityQualifiedBindingRowsSurviveCLI(t *testing.T) { input.Bindings[i].ScenarioID = "scenario.shared" input.Bindings[i].WitnessID = "witness.shared" } + originalSelectors := slices.Clone(input.Bindings[0].WitnessSelectors) otherScenario, otherWitness := input.Bindings[0], input.Bindings[0] otherScenario.ScenarioID = "scenario.other" + otherScenario.WitnessSelectors = []requirementbinding.WitnessSelector{{Command: "go test ./tests -run TestScenarioVariant", Selector: "TestScenarioVariant"}} otherWitness.WitnessID = "witness.other" + otherWitness.WitnessSelectors = []requirementbinding.WitnessSelector{{Command: "go test ./tests -run TestWitnessVariant", Selector: "TestWitnessVariant"}} + if !reflect.DeepEqual(input.Bindings[0].WitnessSelectors, originalSelectors) { + t.Fatal("fixture variants mutated the original selector payload") + } input.Bindings = append(input.Bindings, otherScenario, otherWitness) slices.SortFunc(input.Bindings, func(a, b requirementbinding.Binding) int { for _, pair := range [][2]string{{a.RequirementID, b.RequirementID}, {a.ScenarioID, b.ScenarioID}, {a.WitnessID, b.WitnessID}} {