From 23f9229137e72e942bdd82606d331993c7c7c9c2 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Mon, 31 Aug 2026 16:35:44 +0500 Subject: [PATCH 1/2] feat(validate): report the schema revision a validation actually used `gds validate repository --json` named its target and its findings but not the schemas it judged them against, and nothing else in the CLI did either -- there was no schema digest anywhere in the tree. Callers filled the gap with the binary's version string, and that string identifies nothing. On this machine the binary reports 0.8.0, the source constant is 0.7.0, and the newest release tag is gds-v0.6.0; no tag for either 0.7.0 or 0.8.0 exists. A consumer printing `gds --version` beside a green result is printing a name with no contract behind it, which is the shape this estate keeps finding: a value without the thing that gives it meaning. `Set.Digest()` hashes every embedded schema file, path and content, in the sorted order `fs.ReadDir` returns, and `validate repository` carries it in `data.schema_digest`. It is derived from content, so unlike a build-time stamp it cannot name a revision that was never compiled. Verified in both directions. Adding a property to repository.schema.json and rebuilding moves the digest from 2526481e to c02d033f. The coverage test recomputes the digest over the embedded set independently; excluding file content from the implementation makes it fail with a different digest over 50 schema files, so the assertion can fail rather than merely pass. --- core/app/services.go | 5 +++ core/validation/schema.go | 18 ++++++++- core/validation/schema_test.go | 71 ++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/core/app/services.go b/core/app/services.go index 4eade3b..58d8b7d 100644 --- a/core/app/services.go +++ b/core/app/services.go @@ -810,8 +810,13 @@ func (services *Services) ValidateRepository(ctx context.Context, path string) d } } class := classifyFindings(findings) + // The schema digest travels with the result so a pass can be attributed to + // the schema revision that produced it. The binary's version string cannot do + // that: it is stamped at build time and may name a revision that was never + // released. envelope := domain.NewEnvelope("gds validate repository", class, map[string]any{ "target": info.WorktreeRoot, "anchor": anchorPath, + "schema_digest": services.Schemas.Digest(), }, findings...) return envelope } diff --git a/core/validation/schema.go b/core/validation/schema.go index 11254df..414bb45 100644 --- a/core/validation/schema.go +++ b/core/validation/schema.go @@ -4,6 +4,7 @@ package validation import ( "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -26,6 +27,7 @@ import ( type Set struct { compiled map[string]*jsonschema.Schema + digest string } type offlineLoader struct{} @@ -79,6 +81,11 @@ func NewSchemaSet() (*Set, error) { doc any } resources := []resource{} + // The digest identifies the schema revision a binary actually validates + // against. A version string cannot: it is stamped at build time and can name + // a revision that was never released, so a caller printing it beside a green + // result reports a name with no contract behind it. + digest := sha256.New() for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".schema.json") { continue @@ -92,6 +99,10 @@ func NewSchemaSet() (*Set, error) { if err != nil { return nil, fmt.Errorf("decode embedded schema %s: %w", path, err) } + digest.Write([]byte(path)) + digest.Write([]byte{0}) + digest.Write(raw) + digest.Write([]byte{0}) object, ok := doc.(map[string]any) if !ok { return nil, fmt.Errorf("schema %s root is not an object", path) @@ -121,9 +132,14 @@ func NewSchemaSet() (*Set, error) { } compiled[resource.name] = schema } - return &Set{compiled: compiled}, nil + return &Set{compiled: compiled, digest: "sha256:" + hex.EncodeToString(digest.Sum(nil))}, nil } +// Digest returns the content digest of the embedded schema set this binary +// compiled. Callers record it beside a validation result so a pass can be tied +// to the schema revision that produced it. +func (set *Set) Digest() string { return set.digest } + func (set *Set) Names() []string { names := make([]string, 0, len(set.compiled)) for name := range set.compiled { diff --git a/core/validation/schema_test.go b/core/validation/schema_test.go index 7a90b2e..6cd1b1b 100644 --- a/core/validation/schema_test.go +++ b/core/validation/schema_test.go @@ -1,15 +1,20 @@ package validation import ( + "crypto/sha256" + "encoding/hex" "encoding/json" + "io/fs" "path/filepath" "runtime" + "strings" "sync" "sync/atomic" "testing" "github.com/NDDev-OpenNetwork/github-device-sync/core/domain" "github.com/NDDev-OpenNetwork/github-device-sync/core/serialization" + gdsschemas "github.com/NDDev-OpenNetwork/github-device-sync/schemas" ) func TestEmbeddedSchemasCompile(t *testing.T) { @@ -117,3 +122,69 @@ func toJSONValue(t *testing.T, value any) any { } return decoded } + +// TestSchemaDigestIdentifiesTheEmbeddedSet covers what a version string cannot: +// the digest must name the schema revision this binary compiled, so a caller can +// tie a green validation to the schemas that produced it. A build-time version +// can name a revision that was never released, which is why the digest is +// derived from content rather than stamped. +func TestSchemaDigestIdentifiesTheEmbeddedSet(t *testing.T) { + t.Parallel() + first, err := NewSchemaSet() + if err != nil { + t.Fatalf("NewSchemaSet() error = %v", err) + } + digest := first.Digest() + if !strings.HasPrefix(digest, "sha256:") || len(digest) != len("sha256:")+64 { + t.Fatalf("Digest() = %q, want a sha256: prefix and 64 hex characters", digest) + } + // Determinism matters more than the value: two sets compiled from the same + // embedded schemas must agree, or the digest identifies the run instead of + // the schemas and is worse than printing nothing. + second, err := NewSchemaSet() + if err != nil { + t.Fatalf("NewSchemaSet() second call error = %v", err) + } + if second.Digest() != digest { + t.Fatalf("Digest() is not deterministic: %q then %q", digest, second.Digest()) + } +} + +// TestSchemaDigestCoversEverySchemaFile guards the direction the digest exists +// to guard. A digest that skipped a file would stay identical while that file +// changed, which is the failure it is meant to make impossible. +func TestSchemaDigestCoversEverySchemaFile(t *testing.T) { + t.Parallel() + set, err := NewSchemaSet() + if err != nil { + t.Fatalf("NewSchemaSet() error = %v", err) + } + entries, err := fs.ReadDir(gdsschemas.V1, "v1") + if err != nil { + t.Fatalf("ReadDir() error = %v", err) + } + expected := sha256.New() + files := 0 + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".schema.json") { + continue + } + path := "v1/" + entry.Name() + raw, err := gdsschemas.V1.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", path, err) + } + expected.Write([]byte(path)) + expected.Write([]byte{0}) + expected.Write(raw) + expected.Write([]byte{0}) + files++ + } + if files == 0 { + t.Fatal("no embedded schema files found; the digest would be vacuous") + } + want := "sha256:" + hex.EncodeToString(expected.Sum(nil)) + if set.Digest() != want { + t.Fatalf("Digest() = %q, want %q over %d schema files", set.Digest(), want, files) + } +} From ef71505b71cdffafdcde340d12562e797de7e911 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Mon, 31 Aug 2026 16:51:52 +0500 Subject: [PATCH 2/2] chore(projections): regenerate onto the current source tree `core/app` and `core/validation` are both in the source paths the bundle lock digests, so adding the schema digest changed the source tree identity and left every projection stale against it. Regenerated through `gds generate repository` plan/apply rather than by hand: the two files carry per-file digests in their own headers that cannot be derived by reading them. `gds generate repository --check` now reports no findings. --- .gds/bundle.lock.yaml | 10 +++++----- .github/workflows/gds-ci.yml | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index 0fbe5de..d41ba25 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -5,14 +5,14 @@ bundle: version: "0.4.0-dev" release_sequence: 0 channel: "development" - source_tree_digest: "sha256:017d389794f2e9f7bba28e18cf4dd61c51ed56eef6eb2600e4c8f8b919bce854" - digest: "sha256:9eb5f71d3114d6e5364d996075e8ab12703223c9c5f0bab02af42eef4e9b7b80" + source_tree_digest: "sha256:a18f15d00b467f9585fecf7ee3230f00025f69726e17135f14eb99f3036b5827" + digest: "sha256:7d370fff3455208269852d3bd0f7e4eff1e61b3eb8e80df9c967245bf464cda5" projection: - input_digest: "sha256:cc7b9a4f7c11ad8b00708e56ea2afbe3a33f65f0295baad7ef98b636cb9c16ef" - output_digest: "sha256:a241cbac1606ef94318d54a078c5ebcc6dcc69dbf828676b9dc36f0914016958" + input_digest: "sha256:88f5073a543617f2b37990db826b8777ef8c396eb658de403f60e2e480e15f62" + output_digest: "sha256:d76ab0b017214dce592c2ae8b23e30b89caabf5a0197be3b25dae6d948cd00aa" files: - path: ".gds/compiled-policy.json" digest: "sha256:78d09606bb4168d74bce1f50ab62b46a7ded34652c6b23af1badfd26dd060e94" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:c77919497087a60415fed43024d50a3a0d5c69d4cb5b4ed324db443c772ccf93" + digest: "sha256:1508f13759d4a238114e35d7c6ee6e0f6d2b3b09e18d8fb7eff46b534143cb73" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index f47b44c..b25364c 100644 --- a/.github/workflows/gds-ci.yml +++ b/.github/workflows/gds-ci.yml @@ -1,8 +1,8 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # generator: gds # bundle: 0.4.0-dev -# source-tree-digest: sha256:017d389794f2e9f7bba28e18cf4dd61c51ed56eef6eb2600e4c8f8b919bce854 -# input-digest: sha256:cc7b9a4f7c11ad8b00708e56ea2afbe3a33f65f0295baad7ef98b636cb9c16ef +# source-tree-digest: sha256:a18f15d00b467f9585fecf7ee3230f00025f69726e17135f14eb99f3036b5827 +# input-digest: sha256:88f5073a543617f2b37990db826b8777ef8c396eb658de403f60e2e480e15f62 # output-digest: sha256:01fb4854784be9e4564bcc84e70786484b370879be5e5ab1dd49f8b73ea2dea4 # edit-source: # - .gds/repository.yaml