diff --git a/docs/evidence/2026-09-21-layer-declaration-survey-after-ghdec017.json b/docs/evidence/2026-09-21-layer-declaration-survey-after-ghdec017.json index 0c6de44..97bca2b 100644 --- a/docs/evidence/2026-09-21-layer-declaration-survey-after-ghdec017.json +++ b/docs/evidence/2026-09-21-layer-declaration-survey-after-ghdec017.json @@ -1,5 +1,5 @@ { - "checks_only": "the closed four-token §3 vocabulary, ASCII case folded per GH-DEC-2026-017 §2; no flex-auth house rules applied to peers", + "checks_only": "the closed four-token §3 vocabulary, ASCII case folded per GH-DEC-2026-017 §2; and A12 r2 (GH-DEC-2026-020): no standard or companion version in any key or value of INTENT.md frontmatter or layer.yaml; stance, claims and classification maps not read; no flex-auth house rules applied to peers", "derived_at": "run time", "off_vocab": null, "root": "/home/worsch", @@ -522,5 +522,7 @@ "net-kingdom", "ops-mason" ], + "validated_against": "net-kingdom security-layer-model v0.8 with amendments A9-A13 (A12 r2), gate-house@104f3fc (GH-DEC-2026-017, GH-DEC-2026-020)", + "version_pinned": null, "volunteers": null } diff --git a/internal/layer/conformance.go b/internal/layer/conformance.go index a8e3111..cd15bc7 100644 --- a/internal/layer/conformance.go +++ b/internal/layer/conformance.go @@ -51,6 +51,15 @@ func CanonicalLayer(declared string) (string, bool) { // Vocabulary returns the four §3 tokens in their canonical spelling. func Vocabulary() []string { return append([]string(nil), canonicalLayers...) } +// ValidatedAgainst names the text this package checks against. A12 r2 +// (GH-DEC-2026-020 §4) moves version-scoped state out of the declaration and +// into the run: every run MUST print the version or commit of the standard it +// checked against, and its scope. This constant lives in the checker, which is +// not a declaration, so A12 does not reach it. When the canon moves it goes +// visibly stale in every run's output — which is the point (kings-guard's +// VALIDATED_AGAINST pattern, adopted as the reference by GH-DEC-2026-020). +const ValidatedAgainst = "net-kingdom security-layer-model v0.8 with amendments A9-A13 (A12 r2), gate-house@104f3fc (GH-DEC-2026-017, GH-DEC-2026-020)" + // Engine roles from §3.3. An Engine declaration must state one. var validEngineRoles = map[string]bool{ "PDP": true, @@ -116,6 +125,11 @@ func Check(root string) error { return fmt.Errorf("%s names %q, which is not on disk: §11's derived-artifact rule needs the artifact, not the pointer", named.field, named.path) } } + if pins, err := DeclarationVersionPins(filepath.Join(root, "INTENT.md")); err != nil { + return err + } else if len(pins) > 0 { + return fmt.Errorf("layer declaration carries a standard or companion version (A12 r2, GH-DEC-2026-020 §1-§2): %s", strings.Join(pins, "; ")) + } hits, err := ScanToolingClients(root) if err != nil { return err @@ -185,6 +199,123 @@ func ValidateDeclaration(decl Declaration) error { return nil } +// A12 r2 reaches CONTENT, not a key name (GH-DEC-2026-020 §1). Every checker in +// the estate, this one included, enforced A12 as "no key named standard_version" +// and so could not see the same pin as `standard: …security-layer-model_v0.7.md` +// or as `companion_version: "0.2"`. These detect a version of the standard or of +// its companion anywhere in the declaration. +var ( + // pinKeys are keys whose presence with a value IS the pin (§1, §2). + pinKeys = map[string]bool{"standard_version": true, "companion_version": true} + // versionedRef matches a reference to the standard or its companion that + // carries a version in its path or name, whatever key holds it. + versionedRef = regexp.MustCompile(`(?i)(security-layer-model|security-companion|standard-companion)[^\s]*?[_@/-]v?\d+(\.\d+)+`) + // versionToken matches a bare version, e.g. 0.8 or v0.7, bounded so a date + // or a decision id does not count. Applied only under standard*/companion* keys. + versionToken = regexp.MustCompile(`(?i)(?:^|[^a-z0-9.])v?\d+\.\d+`) +) + +// notReached are keys A12 r2 states it does not reach: the version of a +// declaration file's own schema is not a version of the standard. Comments are +// not reached either, and a YAML parse never surfaces them. +var notReached = map[string]bool{"schema_version": true} + +// DeclarationVersionPins reads a declaration — INTENT.md frontmatter, or a bare +// derived declaration file such as layer.yaml — and returns every key or value +// carrying a version of the standard or its companion. +// +// It MUST be pointed at a declaration only. A stance map, claims map or +// evidence classification (pep-stance.yaml, pip-claims.yaml, +// evidence-classification.yaml) SHOULD carry the version of the clause text it +// answers, and A12 r2 forbids a run applying this rule there (GH-DEC-2026-020 +// §3). Nothing in this package calls it on such a file. +func DeclarationVersionPins(path string) ([]string, error) { + if !isDeclarationPath(path) { + return nil, fmt.Errorf("%s is not a §11 declaration (INTENT.md or layer.yaml); A12 r2 does not reach it and a run MUST NOT apply it there", filepath.Base(path)) + } + body, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read layer declaration: %w", err) + } + doc := string(body) + if strings.HasSuffix(path, ".md") { + if doc, err = splitFrontmatter(doc); err != nil { + return nil, err + } + } + return VersionPins(doc) +} + +func isDeclarationPath(path string) bool { + switch filepath.Base(path) { + case "INTENT.md", "layer.yaml", "layer.yml": + return true + } + return false +} + +// VersionPins scans a bare declaration YAML document. Findings are stable, +// human-readable "key: value" strings in document order. +func VersionPins(doc string) ([]string, error) { + var root yaml.Node + if err := yaml.Unmarshal([]byte(doc), &root); err != nil { + return nil, fmt.Errorf("parse layer declaration: %w", err) + } + var out []string + walkPins(&root, "", &out) + return out, nil +} + +func walkPins(n *yaml.Node, key string, out *[]string) { + switch n.Kind { + case yaml.DocumentNode, yaml.SequenceNode: + for _, c := range n.Content { + walkPins(c, key, out) + } + case yaml.MappingNode: + for i := 0; i+1 < len(n.Content); i += 2 { + k, v := n.Content[i], n.Content[i+1] + name := strings.ToLower(strings.TrimSpace(k.Value)) + if notReached[name] { + continue + } + if versionedRef.MatchString(k.Value) { + *out = append(*out, fmt.Sprintf("key %q names a versioned standard or companion", k.Value)) + } + if pinKeys[name] && !isEmptyNode(v) { + *out = append(*out, fmt.Sprintf("%s: %s", k.Value, nodeText(v))) + continue + } + walkPins(v, name, out) + } + case yaml.ScalarNode: + standardish := key == "standard" || key == "companion" || + strings.HasPrefix(key, "standard_") || strings.HasPrefix(key, "companion_") + if versionedRef.MatchString(n.Value) || (standardish && versionToken.MatchString(n.Value)) { + *out = append(*out, fmt.Sprintf("%s: %s", dash(key), n.Value)) + } + case yaml.AliasNode: + if n.Alias != nil { + walkPins(n.Alias, key, out) + } + } +} + +func isEmptyNode(n *yaml.Node) bool { + if n.Kind == yaml.ScalarNode { + return n.Tag == "!!null" || strings.TrimSpace(n.Value) == "" + } + return len(n.Content) == 0 +} + +func nodeText(n *yaml.Node) string { + if n.Kind == yaml.ScalarNode { + return n.Value + } + b, _ := yaml.Marshal(n) + return strings.TrimSpace(string(b)) +} + // ScanToolingClients returns production Go files that invoke OpenBao/Vault. func ScanToolingClients(root string) ([]string, error) { var hits []string diff --git a/internal/layer/conformance_test.go b/internal/layer/conformance_test.go index 75b59c4..7708022 100644 --- a/internal/layer/conformance_test.go +++ b/internal/layer/conformance_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "github.com/netkingdom/flex-auth/internal/layer" @@ -202,3 +203,99 @@ func repoRoot(t *testing.T) string { } return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) } + +// A12 r2 reaches CONTENT, not a key name (GH-DEC-2026-020 §1, §2). Each of these +// is a version of the standard or its companion that a key-name check on +// `standard_version` could not see. If any of them comes back into a +// declaration, this test fails. +func TestVersionAnywhereInDeclarationIsFound(t *testing.T) { + for name, doc := range map[string]string{ + "versioned standard path": "layer: Engine\nstandard: net-kingdom/canon/standards/security-layer-model_v0.7.md\n", + "versioned companion path": "layer: Engine\ncompanion: net-kingdom/SECURITY-COMPANION_v0.2.md\n", + "companion_version": "layer: Engine\ncompanion_version: \"0.2\"\n", + "standard_version": "layer: Engine\nstandard_version: \"0.8\"\n", + "bare version under standard": "layer: Engine\nstandard: \"v0.8\"\n", + "nested versioned path": "layer: Engine\nassented_by:\n - ref: security-layer-model_v0.8.md\n", + "version in a list of sources": "layer: Engine\nsources: [net-kingdom/canon/standards/security-layer-model_v0.6.md]\n", + } { + pins, err := layer.VersionPins(doc) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if len(pins) == 0 { + t.Errorf("%s: version was not detected in %q", name, doc) + } + } +} + +// What A12 r2 states it does NOT reach: comments, schema_version, and versions +// that are not versions of the standard or its companion. Unversioned standard +// and companion paths are the conforming form. +func TestVersionPinsLeavesWhatA12DoesNotReach(t *testing.T) { + doc := "# declared against security-layer-model_v0.7.md, kept as history\n" + + "schema_version: \"0.2\"\n" + + "intent_version: 0.1.0\n" + + "layer: engine # v0.8 comment\n" + + "standard: net-kingdom/canon/standards/security-layer-model\n" + + "companion: net-kingdom/SECURITY-COMPANION.md\n" + + "declared_at: \"2026-08-29\"\n" + + "declared_by: decisions/decisions.md FLEX-DEC-2026-001\n" + + "companion_version:\n" + pins, err := layer.VersionPins(doc) + if err != nil { + t.Fatal(err) + } + if len(pins) != 0 { + t.Fatalf("A12 r2 reached what it does not reach: %v", pins) + } +} + +// Check must fail on a declaration whose only pin is in the standard: path. +// Stance, claims and classification maps SHOULD carry the version of the text +// they answer; a run MUST NOT apply A12 to them (GH-DEC-2026-020 §3). +func TestCheckRejectsVersionedStandardPathButNotStanceMaps(t *testing.T) { + good := "---\nlayer: Engine\nrole: PDP\nconformance_record: record.md\nsource_of_evidence: false\n" + + "standard: net-kingdom/canon/standards/security-layer-model\n---\n\n# x\n" + dir := t.TempDir() + for name, body := range map[string]string{ + "INTENT.md": good, + "record.md": "x\n", + "pep-stance.yaml": "standard_version: \"0.8\"\n", + "pip-claims.yaml": "standard_version: \"0.8\"\nstandard: security-layer-model_v0.8.md\n", + "evidence-classification.yaml": "standard_version: \"0.8\"\n", + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + if err := layer.Check(dir); err != nil { + t.Fatalf("a version in a stance/claims/classification map failed the declaration check: %v", err) + } + for _, f := range []string{"pep-stance.yaml", "pip-claims.yaml", "evidence-classification.yaml"} { + if _, err := layer.DeclarationVersionPins(filepath.Join(dir, f)); err == nil { + t.Errorf("DeclarationVersionPins accepted %s; A12 r2 must not be applied to it", f) + } + } + + bad := strings.Replace(good, "security-layer-model\n", "security-layer-model_v0.7.md\n", 1) + if err := os.WriteFile(filepath.Join(dir, "INTENT.md"), []byte(bad), 0o644); err != nil { + t.Fatal(err) + } + if err := layer.Check(dir); err == nil { + t.Fatal("a versioned standard: path in INTENT.md was accepted") + } +} + +// flex-auth's own declaration carries no version under any key (A12 r2). +func TestOwnDeclarationCarriesNoVersionAnywhere(t *testing.T) { + pins, err := layer.DeclarationVersionPins(filepath.Join(repoRoot(t), "INTENT.md")) + if err != nil { + t.Fatal(err) + } + if len(pins) != 0 { + t.Fatalf("INTENT.md declaration carries a version: %v", pins) + } + if layer.ValidatedAgainst == "" { + t.Fatal("the checker must state the version it validates against on every run") + } +} diff --git a/internal/layer/survey.go b/internal/layer/survey.go index aedb5da..141aef6 100644 --- a/internal/layer/survey.go +++ b/internal/layer/survey.go @@ -25,6 +25,11 @@ type Form struct { InVocabulary bool // Canonical is the §4 column spelling of Layer, empty when out of vocabulary. Canonical string + // VersionPins lists every key or value of this declaration form carrying a + // version of the standard or its companion (A12 r2, GH-DEC-2026-020 §1-§2). + // Read from the declaration only — never from a stance, claims or + // classification map, which A12 r2 does not reach (§3). + VersionPins []string `json:",omitempty"` } // SurveyRow is one repository's declaration as observed from outside. It holds @@ -144,8 +149,11 @@ func EstateScope(repos []string) Scope { // SurveyDeclarations reads both §11 forms for every repository under root. // -// It checks ONE property: whether each layer: value sits in the §3 vocabulary -// as written. It deliberately does not apply flex-auth's own declaration rules +// It checks TWO properties, both §11 statute rather than house rule: whether +// each layer: value sits in the §3 vocabulary (case folded), and whether the +// declaration carries a standard or companion version anywhere (A12 r2). It +// reads INTENT.md frontmatter and layer.yaml only, so a peer's pep-stance.yaml, +// pip-claims.yaml or evidence-classification.yaml is never graded. It deliberately does not apply flex-auth's own declaration rules // — pep_stance, tooling_contacts, conformance_record — to any other repository. // Those are flex-auth's invariants for flex-auth, and §11 is explicit that a // layer stated about a repository by another repository is not a declaration. @@ -179,7 +187,9 @@ func readForm(root, rel string) Form { return Form{} } canon, ok := CanonicalLayer(decl.Layer) + pins, _ := DeclarationVersionPins(path) return Form{ + VersionPins: pins, Source: rel, Layer: decl.Layer, Role: decl.Role, @@ -261,6 +271,20 @@ func SelfDisagreeing(rows []SurveyRow) []SurveyRow { return out } +// VersionPinned lists each declaration form carrying a standard or companion +// version, as "source: finding". +func VersionPinned(rows []SurveyRow) []string { + var out []string + for _, r := range rows { + for _, f := range []Form{r.Intent, r.File} { + for _, p := range f.VersionPins { + out = append(out, f.Source+": "+p) + } + } + } + return out +} + // VolunteerDeclarations lists repositories outside §4 that declared anyway. The // correct report for them is "declared voluntarily, outside catalog scope" // (GH-DEC-2026-017 §4): treating a volunteer's declaration as a non-conformance @@ -280,7 +304,9 @@ func VolunteerDeclarations(rows []SurveyRow) []string { // the scope because §11 requires a run to state what it ranged over. func FormatSurvey(scope Scope, rows []SurveyRow) string { var b strings.Builder - fmt.Fprintf(&b, "Scope: %s — %s\n\n", scope.Name, scope.Statement) + fmt.Fprintf(&b, "Validated against: %s\n", ValidatedAgainst) + fmt.Fprintf(&b, "Scope: %s — %s\n", scope.Name, scope.Statement) + fmt.Fprintf(&b, "Reads: INTENT.md frontmatter and layer.yaml/layer.yml per repository; no stance, claims or classification map is read.\n\n") fmt.Fprintf(&b, "%-18s %-16s %-16s %s\n", "REPO", "INTENT.md", "DECL FILE", "NOTE") for _, r := range rows { note := "" @@ -303,6 +329,11 @@ func FormatSurvey(scope Scope, rows []SurveyRow) string { note += "; " + f.Layer + " is outside the closed §3 vocabulary" } } + for _, f := range []Form{r.Intent, r.File} { + if len(f.VersionPins) > 0 { + note += "; " + f.Source + " carries a version (A12 r2)" + } + } if r.Declared() && !r.InCatalog { note += "; declared voluntarily, outside §4 catalog scope" } diff --git a/internal/layer/survey_test.go b/internal/layer/survey_test.go index 9f11346..41105ff 100644 --- a/internal/layer/survey_test.go +++ b/internal/layer/survey_test.go @@ -3,6 +3,7 @@ package layer_test import ( "os" "path/filepath" + "strings" "testing" "github.com/netkingdom/flex-auth/internal/layer" @@ -172,3 +173,37 @@ func TestPeerWithForeignFieldShapesIsStillSurveyed(t *testing.T) { t.Fatalf("audit-core's layer.yaml was dropped: %+v", rows[0].File) } } + +// The survey applies A12 r2 to peers' declarations — both forms — and states +// the version it checks against on every run. +func TestSurveyFindsVersionInPeerDeclaration(t *testing.T) { + root := t.TempDir() + writeRepo(t, root, "audit-core", "Engine", "") + writeRepo(t, root, "approval-engine", "Engine", "") + writeRepo(t, root, "user-engine", "Engine", "") + must := func(err error) { + if err != nil { + t.Fatal(err) + } + } + must(os.WriteFile(filepath.Join(root, "audit-core", "INTENT.md"), + []byte("---\nlayer: Engine\nstandard: canon/standards/security-layer-model_v0.7.md\n---\n"), 0o644)) + must(os.WriteFile(filepath.Join(root, "approval-engine", "layer.yaml"), + []byte("schema_version: \"0.1\"\nlayer: engine\ncompanion_version: \"0.2\"\n"), 0o644)) + // A stance map is not a declaration and must not be graded. + must(os.WriteFile(filepath.Join(root, "user-engine", "pep-stance.yaml"), + []byte("standard_version: \"0.8\"\n"), 0o644)) + + rows, err := layer.SurveyDeclarations(root, []string{"approval-engine", "audit-core", "user-engine"}) + if err != nil { + t.Fatal(err) + } + got := layer.VersionPinned(rows) + if len(got) != 2 { + t.Fatalf("VersionPinned = %v; want the audit-core standard: path and approval-engine companion_version only", got) + } + out := layer.FormatSurvey(layer.EstateScope([]string{"approval-engine", "audit-core", "user-engine"}), rows) + if !strings.Contains(out, layer.ValidatedAgainst) || !strings.Contains(out, "Scope:") { + t.Fatal("every survey run must print the version it checks against and its scope") + } +} diff --git a/tools/check_layer_conformance.go b/tools/check_layer_conformance.go index bf492ef..5749a65 100644 --- a/tools/check_layer_conformance.go +++ b/tools/check_layer_conformance.go @@ -1,5 +1,8 @@ // Command check_layer_conformance asserts the INTENT.md layer declaration // and that no Tooling client exists in production Go sources. +// +// Every run — pass or fail — prints the standard version it checks against and +// the scope it ranged over, as A12 r2 requires (GH-DEC-2026-020 §4). package main import ( @@ -16,6 +19,8 @@ func main() { fmt.Fprintln(os.Stderr, err) os.Exit(2) } + fmt.Printf("Validated against: %s\n", layer.ValidatedAgainst) + fmt.Printf("Scope: this repository only — %s/INTENT.md frontmatter (the §11 declaration), the files it names, and production Go sources under cmd/, internal/, pkg/. Stance, claims and classification maps are not read.\n", filepath.Base(root)) if _, err := os.Stat(filepath.Join(root, "INTENT.md")); err != nil { fmt.Fprintf(os.Stderr, "INTENT.md not found in %s\n", root) os.Exit(2) @@ -24,5 +29,5 @@ func main() { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - fmt.Println("PASS — Engine/PDP declaration parses; no Tooling client in the tree.") + fmt.Println("PASS — Engine/PDP declaration parses; no standard or companion version anywhere in it; no Tooling client in the tree.") } diff --git a/tools/survey_layer_declarations.go b/tools/survey_layer_declarations.go index 9b80e12..c16b55c 100644 --- a/tools/survey_layer_declarations.go +++ b/tools/survey_layer_declarations.go @@ -9,8 +9,12 @@ // shell survey, which is the same defect in a different costume — a finding // nobody can reproduce is an assertion. This makes it a command. // -// It checks ONE property: whether each layer: value sits in the §3 vocabulary -// as written, case-sensitively. It does not apply flex-auth's own declaration +// It checks TWO properties: whether each layer: value sits in the §3 +// vocabulary (ASCII case folded, GH-DEC-2026-017 §2), and whether a +// declaration carries a standard or companion version in any key or value +// (A12 r2, GH-DEC-2026-020). It reads declarations only — INTENT.md frontmatter +// and layer.yaml — never stance, claims or classification maps. Every run prints +// the version it checks against and its scope. It does not apply flex-auth's own declaration // rules to any other repository, and it does not grade anyone: §11 is explicit // that a layer stated about a repository by another repository is not a // declaration. @@ -131,22 +135,34 @@ func main() { fmt.Printf("\nOutside the closed §3 vocabulary (four tokens, case folded): %s\n", strings.Join(offVocab, ", ")) } + pinned := layer.VersionPinned(rows) + if len(pinned) > 0 { + fmt.Printf("\nDeclarations carrying a standard or companion version (A12 r2): %d\n", len(pinned)) + for _, p := range pinned { + fmt.Printf(" %s\n", p) + } + } else { + fmt.Println("\nNo declaration carries a standard or companion version (A12 r2).") + } + if vol := layer.VolunteerDeclarations(rows); len(vol) > 0 { fmt.Printf("\nDeclared voluntarily, outside §4 catalog scope — welcome, and NOT a §11\nnon-conformance: %s\n", strings.Join(vol, ", ")) } if *jsonOut != "" { receipt := map[string]any{ - "derived_at": "run time", - "scope": scope, - "root": dir, - "rows": rows, - "spellings": spellings, - "undeclared": undeclared, - "off_vocab": offVocab, - "self_disagreeing": disagree, - "volunteers": layer.VolunteerDeclarations(rows), - "checks_only": "the closed four-token §3 vocabulary, ASCII case folded per GH-DEC-2026-017 §2; no flex-auth house rules applied to peers", + "derived_at": "run time", + "validated_against": layer.ValidatedAgainst, + "version_pinned": pinned, + "scope": scope, + "root": dir, + "rows": rows, + "spellings": spellings, + "undeclared": undeclared, + "off_vocab": offVocab, + "self_disagreeing": disagree, + "volunteers": layer.VolunteerDeclarations(rows), + "checks_only": "the closed four-token §3 vocabulary, ASCII case folded per GH-DEC-2026-017 §2; and A12 r2 (GH-DEC-2026-020): no standard or companion version in any key or value of INTENT.md frontmatter or layer.yaml; stance, claims and classification maps not read; no flex-auth house rules applied to peers", } b, err := json.MarshalIndent(receipt, "", " ") if err != nil {