From d2879cbd3f5d96ad7a8a30182a4bcc7a847510c4 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Sat, 3 Oct 2026 15:52:40 +0500 Subject: [PATCH 1/4] feat: trait-based placement and provider-account tenancy Tenancy is the provider account: one personal user account plus organizations by exact provider login, never purpose groupings. Device materialization gains `match` (owner_login, names, name_prefixes, visibility) evaluated against repository facts with first-match-wins ordering, so placement no longer requires a portfolio label and several trait rules may share one workspace root. Policy matching gains `name_prefixes` for cross-owner families such as `server-*`. `selector` membership placement and portfolio policy matching stay valid for compatibility; GDS_WORKSPACE_PLACEMENT_AMBIGUOUS is retired in favour of deterministic ordering. ADR 0040 records the decision. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- core/compiler/compiler.go | 13 +++ core/compiler/types.go | 1 + core/estate/compiler_test.go | 11 +-- core/validation/estate.go | 10 +++ core/validation/estate_test.go | 6 +- core/validation/schema.go | 24 ++++- core/workspace/device.go | 87 +++++++++++++++---- core/workspace/device_test.go | 48 +++++++++- .../0040-tenancy-is-the-provider-account.md | 74 ++++++++++++++++ docs/adr/README.md | 1 + docs/contracts/estate-v1.md | 25 ++++-- estate/devices/example-user-mac2.yaml | 3 +- estate/devices/example-user-ubuntu-1.yaml | 3 +- estate/devices/example-workstation.yaml | 23 ++++- estate/owners/example-guild.yaml | 2 +- estate/owners/example-media.yaml | 2 +- estate/owners/example-org.yaml | 2 +- estate/owners/example-user.yaml | 2 +- estate/owners/opennetwork.yaml | 2 +- estate/selectors/guild-sources.yaml | 2 +- estate/selectors/media-sources.yaml | 2 +- estate/selectors/opennetwork-sources.yaml | 2 +- estate/selectors/organization-servers.yaml | 2 +- estate/selectors/organization-sources.yaml | 2 +- estate/selectors/personal-servers.yaml | 2 +- estate/selectors/personal-sources.yaml | 2 +- policies/portfolios/servers-default.yaml | 11 +-- schemas/v1/device.schema.json | 56 +++++++++++- schemas/v1/policy.schema.json | 11 +++ scripts/validate_gds_schemas.py | 15 +++- skills/canonical/gds-audit-estate/SKILL.md | 3 +- .../gds-reconcile-provider-change/SKILL.md | 7 +- 32 files changed, 390 insertions(+), 66 deletions(-) create mode 100644 docs/adr/0040-tenancy-is-the-provider-account.md diff --git a/core/compiler/compiler.go b/core/compiler/compiler.go index 7f8588c..76435ef 100644 --- a/core/compiler/compiler.go +++ b/core/compiler/compiler.go @@ -577,6 +577,9 @@ func matchFailure( if len(match.Portfolios) != 0 && !intersects(match.Portfolios, anchor.Classification.Portfolios) { return "repository portfolios do not match" } + if len(match.NamePrefixes) != 0 && !nameHasPrefix(anchor.Provider.Name, match.NamePrefixes) { + return "repository name does not match" + } if len(match.VisibilityContract) != 0 && !contains(match.VisibilityContract, anchor.Classification.VisibilityContract) { return "visibility contract does not match" @@ -807,6 +810,16 @@ func contains(values []string, expected string) bool { return false } +func nameHasPrefix(name string, prefixes []string) bool { + lowered := strings.ToLower(name) + for _, prefix := range prefixes { + if strings.HasPrefix(lowered, strings.ToLower(prefix)) { + return true + } + } + return false +} + func policyFinding(code string, source PolicySource, message, path string) domain.Finding { return domain.Finding{ Code: code, Severity: domain.SeverityHigh, Message: message + ".", diff --git a/core/compiler/types.go b/core/compiler/types.go index 937c509..4dd12c9 100644 --- a/core/compiler/types.go +++ b/core/compiler/types.go @@ -35,6 +35,7 @@ type PolicyMatch struct { Owner string `json:"owner,omitempty"` Roles []string `json:"roles,omitempty"` Portfolios []string `json:"portfolios,omitempty"` + NamePrefixes []string `json:"name_prefixes,omitempty"` VisibilityContract []string `json:"visibility_contract,omitempty"` Lifecycle []string `json:"lifecycle,omitempty"` } diff --git a/core/estate/compiler_test.go b/core/estate/compiler_test.go index 8aed085..0f377d6 100644 --- a/core/estate/compiler_test.go +++ b/core/estate/compiler_test.go @@ -141,20 +141,21 @@ func TestCompileRoutesServerRepositoriesByNamePrefix(t *testing.T) { byID[assignment.ProviderID] = assignment } if got := byID[10]; got.MatchedSelector != "organization-servers" || - len(got.Portfolios) != 1 || got.Portfolios[0] != "portfolio:servers" { + len(got.Portfolios) != 1 || got.Portfolios[0] != "portfolio:example-org" { t.Fatalf("organization server repository = %#v", got) } if got := byID[11]; got.MatchedSelector != "organization-sources" || - !containsString(got.Portfolios, "portfolio:organization-projects") { + !containsString(got.Portfolios, "portfolio:example-org") { t.Fatalf("organization non-server repository = %#v", got) } if got := byID[12]; got.MatchedSelector != "personal-servers" || - len(got.Portfolios) != 1 || got.Portfolios[0] != "portfolio:servers" { + len(got.Portfolios) != 1 || got.Portfolios[0] != "portfolio:example-user" { t.Fatalf("personal server repository = %#v", got) } - // The name prefix decides, and being a fork no longer overrides it. + // The name prefix decides the selector, and being a fork no longer + // overrides it; the assigned portfolio stays the owner's tenancy. if got := byID[13]; got.MatchedSelector != "organization-servers" || - !containsString(got.Portfolios, "portfolio:servers") { + !containsString(got.Portfolios, "portfolio:example-org") { t.Fatalf("server-named organization fork repository = %#v", got) } } diff --git a/core/validation/estate.go b/core/validation/estate.go index 80edf83..ba558ce 100644 --- a/core/validation/estate.go +++ b/core/validation/estate.go @@ -1,6 +1,7 @@ package validation import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -346,6 +347,15 @@ func (set *Set) ValidateEstateTree(root string) (EstateSummary, []domain.Finding assignment, _ := rawAssignment.(map[string]any) selector := stringField(assignment, "selector") workspaceRoot := stringField(assignment, "workspace_root") + // Trait-matched includes key on their canonical match content so + // several match rules may precede selector rules on one device. + if selector == "" { + if matchValue, found := assignment["match"]; found { + if encoded, err := json.Marshal(matchValue); err == nil { + selector = "match:" + string(encoded) + } + } + } if _, duplicate := seenSelectors[selector]; duplicate { findings = append(findings, domain.Finding{ Code: "GDS_ESTATE_DEVICE_SELECTOR_DUPLICATE", Severity: domain.SeverityHigh, diff --git a/core/validation/estate_test.go b/core/validation/estate_test.go index 034fa62..b8bdef4 100644 --- a/core/validation/estate_test.go +++ b/core/validation/estate_test.go @@ -64,7 +64,7 @@ func TestEstateTreeRejectsCanonicalSelectorPortfolioMismatch(t *testing.T) { t.Fatal(err) } raw = []byte(strings.Replace( - string(raw), "portfolio:organization-projects", "portfolio:servers", 1, + string(raw), "portfolio:example-guild", "portfolio:servers", 1, )) if err := os.WriteFile(selectorPath, raw, 0o600); err != nil { t.Fatal(err) @@ -318,8 +318,8 @@ func TestEstateTreeRejectsPolicyReferencesThatResolveToNothing(t *testing.T) { }, { name: "portfolio", - old: ` - "portfolio:servers"`, - new: ` - "portfolio:no-selector-assigns-this"`, + old: " name_prefixes:\n - \"server-\"", + new: " portfolios:\n - \"portfolio:no-selector-assigns-this\"", code: "GDS_ESTATE_POLICY_PORTFOLIO_MISSING", }, } { diff --git a/core/validation/schema.go b/core/validation/schema.go index d111d95..2f6d040 100644 --- a/core/validation/schema.go +++ b/core/validation/schema.go @@ -427,15 +427,26 @@ func deviceFindings(source string, object map[string]any) []domain.Finding { assignment, _ := raw.(map[string]any) selector, _ := assignment["selector"].(string) workspaceRoot, _ := assignment["workspace_root"].(string) - if selector != "" { - if _, duplicate := selectors[selector]; duplicate { + // A `match` entry is keyed by its canonical content so two identical + // trait rules are still flagged while distinct trait rules may share a + // root -- first-match-wins ordering makes that unambiguous. + assignmentKey := selector + if assignmentKey == "" { + if matchValue, found := assignment["match"]; found { + if encoded, err := json.Marshal(matchValue); err == nil { + assignmentKey = "match:" + string(encoded) + } + } + } + if assignmentKey != "" { + if _, duplicate := selectors[assignmentKey]; duplicate { findings = append(findings, domain.Finding{ Code: "GDS_DEVICE_SELECTOR_DUPLICATE", Severity: domain.SeverityHigh, Message: "Device materialization selectors must be unique.", - Evidence: map[string]any{"source": source, "index": index, "selector": selector}, + Evidence: map[string]any{"source": source, "index": index, "selector": assignmentKey}, }) } - selectors[selector] = struct{}{} + selectors[assignmentKey] = struct{}{} } if workspaceRoot == "" { continue @@ -447,6 +458,11 @@ func deviceFindings(source string, object map[string]any) []domain.Finding { Evidence: map[string]any{"source": source, "index": index, "workspace_root": workspaceRoot}, }) } + // The one-root-one-selector rule applies to label selectors only; trait + // matches are disjoint by construction or ordered by first-match-wins. + if selector == "" { + continue + } if prior, reused := usedRoots[workspaceRoot]; reused && prior != selector { findings = append(findings, domain.Finding{ Code: "GDS_DEVICE_WORKSPACE_ROOT_REUSED", Severity: domain.SeverityHigh, diff --git a/core/workspace/device.go b/core/workspace/device.go index ff3b589..4488f4e 100644 --- a/core/workspace/device.go +++ b/core/workspace/device.go @@ -84,9 +84,59 @@ type MaterializationPolicy struct { } type MaterializationAssignment struct { - Selector string `json:"selector"` - WorkspaceRoot string `json:"workspace_root"` - Mode string `json:"mode"` + Selector string `json:"selector,omitempty"` + Match *PlacementMatch `json:"match,omitempty"` + WorkspaceRoot string `json:"workspace_root"` + Mode string `json:"mode"` +} + +// PlacementMatch selects repositories by their own facts -- provider owner +// login, repository name and visibility contract -- so placement does not +// depend on a label the anchor must carry. Every declared field must match; +// an empty field matches nothing by itself but narrows nothing either. +type PlacementMatch struct { + OwnerLogin string `json:"owner_login,omitempty"` + Names []string `json:"names,omitempty"` + NamePrefixes []string `json:"name_prefixes,omitempty"` + Visibility []string `json:"visibility,omitempty"` +} + +func (match PlacementMatch) satisfiedBy(anchor domain.RepositoryAnchor) bool { + if match.OwnerLogin != "" && + !strings.EqualFold(match.OwnerLogin, anchor.Provider.Owner) { + return false + } + if len(match.Names) != 0 { + found := false + for _, name := range match.Names { + if strings.EqualFold(name, anchor.Provider.Name) { + found = true + break + } + } + if !found { + return false + } + } + if len(match.NamePrefixes) != 0 { + found := false + for _, prefix := range match.NamePrefixes { + if strings.HasPrefix( + strings.ToLower(anchor.Provider.Name), strings.ToLower(prefix), + ) { + found = true + break + } + } + if !found { + return false + } + } + if len(match.Visibility) != 0 && + !contains(match.Visibility, anchor.Classification.VisibilityContract) { + return false + } + return true } type DeviceStatePolicy struct { @@ -165,13 +215,27 @@ func ResolvePlacement( anchor domain.RepositoryAnchor, environment Environment, ) (Placement, []domain.Finding) { - matches := make([]MaterializationAssignment, 0, 1) - for _, assignment := range descriptor.Materialization.Include { - if contains(anchor.Classification.Portfolios, assignment.Selector) { - matches = append(matches, assignment) + // Includes are evaluated in declaration order and the first matching + // assignment wins. A specialized rule therefore precedes the generic rule + // it narrows, and a `selector` membership test and a `match` trait test may + // coexist in one list without ambiguity findings. + var assignment *MaterializationAssignment + for index := range descriptor.Materialization.Include { + candidate := descriptor.Materialization.Include[index] + if candidate.Match != nil { + if candidate.Match.satisfiedBy(anchor) { + assignment = &descriptor.Materialization.Include[index] + break + } + continue + } + if candidate.Selector != "" && + contains(anchor.Classification.Portfolios, candidate.Selector) { + assignment = &descriptor.Materialization.Include[index] + break } } - if len(matches) == 0 { + if assignment == nil { return Placement{ DeviceID: descriptor.Device.ID, RepositoryID: anchor.Repository.ID, Mode: descriptor.Materialization.DefaultMode, @@ -180,13 +244,6 @@ func ResolvePlacement( "Repository does not match a device materialization assignment.", anchor.Repository.ID, )} } - if len(matches) != 1 { - return Placement{}, []domain.Finding{workspaceFinding( - "GDS_WORKSPACE_PLACEMENT_AMBIGUOUS", - "Repository matches more than one device materialization assignment.", anchor.Repository.ID, - )} - } - assignment := matches[0] portableRoot, found := descriptor.WorkspaceRoots[assignment.WorkspaceRoot] if !found { return Placement{}, []domain.Finding{workspaceFinding( diff --git a/core/workspace/device_test.go b/core/workspace/device_test.go index 6cfdbdd..1cd708e 100644 --- a/core/workspace/device_test.go +++ b/core/workspace/device_test.go @@ -24,7 +24,7 @@ func TestResolvePlacementUsesOnePortfolioAssignment(t *testing.T) { } } -func TestResolvePlacementRejectsAmbiguousAssignments(t *testing.T) { +func TestResolvePlacementFirstMatchWins(t *testing.T) { descriptor := testDevice() descriptor.Materialization.Include = append(descriptor.Materialization.Include, MaterializationAssignment{ Selector: "portfolio:public-modules", WorkspaceRoot: "personal", Mode: "reference", @@ -32,12 +32,54 @@ func TestResolvePlacementRejectsAmbiguousAssignments(t *testing.T) { anchor := testWorkspaceAnchor() anchor.Classification.Portfolios = append(anchor.Classification.Portfolios, "portfolio:public-modules") home := filepath.Join(string(filepath.Separator), "home", "owner") - _, findings := ResolvePlacement(descriptor, anchor, Environment{ + placement, findings := ResolvePlacement(descriptor, anchor, Environment{ + Home: home, XDGStateHome: filepath.Join(home, ".local", "state"), + }) + if len(findings) != 0 { + t.Fatalf("findings=%#v", findings) + } + if placement.Selector != "portfolio:personal-projects" { + t.Fatalf("placement=%#v", placement) + } +} + +func TestResolvePlacementTraitMatch(t *testing.T) { + descriptor := testDevice() + descriptor.WorkspaceRoots["servers"] = "${HOME}/Developer/servers" + descriptor.Materialization.Include = []MaterializationAssignment{ + { + Match: &PlacementMatch{ + OwnerLogin: "Example-Org", + NamePrefixes: []string{"server-"}, + }, + WorkspaceRoot: "servers", Mode: "active", + }, + { + Match: &PlacementMatch{OwnerLogin: "example-org"}, + WorkspaceRoot: "personal", Mode: "active", + }, + } + anchor := testWorkspaceAnchor() + anchor.Provider.Owner = "example-org" + anchor.Provider.Name = "server-testbed" + home := filepath.Join(string(filepath.Separator), "home", "owner") + placement, findings := ResolvePlacement(descriptor, anchor, Environment{ Home: home, XDGStateHome: filepath.Join(home, ".local", "state"), }) - if len(findings) != 1 || findings[0].Code != "GDS_WORKSPACE_PLACEMENT_AMBIGUOUS" { + if len(findings) != 0 { t.Fatalf("findings=%#v", findings) } + if placement.WorkspaceRoot != filepath.Join(home, "Developer", "servers") { + t.Fatalf("placement=%#v", placement) + } + + anchor.Provider.Name = "plain-project" + placement, findings = ResolvePlacement(descriptor, anchor, Environment{ + Home: home, XDGStateHome: filepath.Join(home, ".local", "state"), + }) + if len(findings) != 0 || placement.WorkspaceRoot != filepath.Join(home, "Developer", "personal") { + t.Fatalf("placement=%#v findings=%#v", placement, findings) + } } func testDevice() DeviceDescriptor { diff --git a/docs/adr/0040-tenancy-is-the-provider-account.md b/docs/adr/0040-tenancy-is-the-provider-account.md new file mode 100644 index 0000000..65c5240 --- /dev/null +++ b/docs/adr/0040-tenancy-is-the-provider-account.md @@ -0,0 +1,74 @@ +# ADR 0040: Tenancy is the provider account + +Status: Accepted + +Date: 2026-10-03 + +## Context + +The estate taxonomy grew two independent axes: *who owns the repository* +(`owner:` records bound to installations and provider logins) and *what kind +of work it is* (purpose portfolios such as `servers`, `forks`, +`*-projects`). The second axis predates trait-based placement and survived as +inertia: ADR 0026 collapsed per-owner server portfolios into flat +`portfolio:servers`, and ADR 0032 did the same for `portfolio:forks`, purely +because one workspace root may not serve two portfolio selectors. + +That coupling has real costs: + +- Classification pretended to answer a placement question. `servers` and + `forks` are not kinds of repository; they are directories that happened to + want one. A fork of `example-user` and a fork of `example-org` have nothing + in common except a filesystem preference. +- `fork_portfolio` and `match.fork` decayed into dead configuration that + still had to be maintained: parsed, validated, and never read by the + compiler (`core/estate/compiler.go`). +- Selector assignment was forced to emit a portfolio (`minItems: 1`), so + every estate paid for the abstraction whether it used it or not. +- `GDS_DEVICE_WORKSPACE_ROOT_REUSED` existed only to keep placement + injective when placement keys were labels. Trait matches do not need it: + two rules can name the same root and stay disjoint by construction. + +## Decision + +**A tenant is a provider account — nothing more, nothing else.** One +personal user account, and otherwise organizations by their exact provider +login. The model keeps `portfolio:` as the reference namespace but its values +are tenant names (`portfolio:nddev-it-com`, `portfolio:example-user`), never +purpose names. + +1. **`materialization.include[].match` places repositories by facts, not + labels.** `match.owner_login`, `match.names`, `match.name_prefixes` and + `match.visibility` are evaluated against the repository's provider + identity and visibility contract. Includes evaluate in declaration order; + the first match wins. `selector` membership placement stays valid for + compatibility but is no longer the reference mechanism. +2. **`policy.match.name_prefixes` lets cross-owner policies exist without a + fake grouping.** A portfolio-tier policy that governed "everything named + `server-*`" now says exactly that. The `portfolio` policy tier stays for + policies that genuinely name a tenant. +3. **Dead fork plumbing stays readable, not normative.** `match.fork` and + `classification.fork_portfolio` remain in the schema for compatibility + with historical documents; the compiler already ignored them and now says + so in the contract. +4. **`GDS_WORKSPACE_PLACEMENT_AMBIGUOUS` is retired.** Ambiguity was a + symptom of unordered label membership; ordered first-match-wins removes + it. A misconfigured include list now has one deterministic reading, and + `GDS_DEVICE_SELECTOR_DUPLICATE` still rejects literally repeated rules — + keyed on canonical match content for trait entries. + +## Consequences + +- Estates that want the old purpose-portfolio grouping lose nothing + semantically: a portfolio is still a legal assignment output. The + difference is that nothing *requires* one beyond a tenant name, and + placement no longer reads anchors' `classification.portfolios` when a + `match` is present. +- An estate that previously used `portfolio:servers` for both policy and + placement splits the two concerns: placement moves to + `include[].match.name_prefixes`, policy moves to + `match.name_prefixes`. The anchor keeps only its tenant. +- `gds portfolio plan` keeps its `portfolio:`/`owner:` target grammar; + tenant portfolios remain valid plan scopes. +- `schema_version: 1` is preserved: every change is additive or a + relaxation, so existing v1 documents stay valid. diff --git a/docs/adr/README.md b/docs/adr/README.md index e31b9d8..a9a7de8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,6 +8,7 @@ clause stops being normative. | ADR | Title | Status | Supersedes | Superseded by | |---|---|---|---|---| +| [0040](0040-tenancy-is-the-provider-account.md) | Tenancy is the provider account | Accepted | — | — | | [0039](0039-repo-scoped-session-evidence.md) | Repo-scoped session evidence | Accepted | — | — | | [0038](0038-release-identity-carries-no-channel.md) | Release identity carries no channel | Accepted | ADR 0016 | — | | [0037](0037-seven-harnesses-one-per-setup-system.md) | Seven harnesses, one per setup system | Accepted | ADR 0011 | — | diff --git a/docs/contracts/estate-v1.md b/docs/contracts/estate-v1.md index 16c347a..5533e6d 100644 --- a/docs/contracts/estate-v1.md +++ b/docs/contracts/estate-v1.md @@ -69,12 +69,18 @@ The compiler: establish that fact. Historical `unassigned` assignments remain readable and retain their original JSON representation for signed audit verification. -Repositories are classified by their owning account. The legacy `match.fork` -and `classification.fork_portfolio` fields remain readable for compatibility -but do not select a separate portfolio. Archive and name-specific selectors -retain their own priority. Organization and personal server portfolios -use distinct device workspace roots so their filesystem placement remains -injective even when owners contain repositories with the same name. +Repositories are classified by their owning account. The tenancy model is +exactly the provider account: one personal user account and otherwise +organizations by their exact provider login; an estate with no need for a +purpose grouping assigns each repository the owner's tenancy portfolio and +nothing else. The legacy `match.fork` and `classification.fork_portfolio` +fields remain readable for compatibility but do not select a separate +portfolio. Archive and name-specific selectors retain their own priority. +Device placement no longer requires a purpose portfolio at all: +`materialization.include[].match` selects repositories by provider facts +(owner login, exact names, name prefixes, visibility contract) with +first-match-wins ordering, so a `server-` family can span several owners under +one root while every other repository lands under its owner's root. The shipped selectors use these priority bands: @@ -124,9 +130,10 @@ than silently accepted. The compiler enforces these in `monotonicStrength` installation for every mutation capability; - owner-to-installation existence and account-login agreement; - selector-to-owner existence; -- source/fork selector portfolio agreement with the owner descriptor; -- unique device portfolio selectors, existing workspace-root references, and - no workspace-root reuse across portfolio assignments; +- selector-to-source-portfolio agreement with the owner descriptor; +- unique device materialization rules (by selector name or by canonical match + content), existing workspace-root references, and no workspace-root reuse + across label selectors (trait matches may share a root); - non-symlink source documents; - that a device inventory recording a consumer checkout also records every submodule declared beneath it, and records each with `materialization: diff --git a/estate/devices/example-user-mac2.yaml b/estate/devices/example-user-mac2.yaml index fd08d23..74f6045 100644 --- a/estate/devices/example-user-mac2.yaml +++ b/estate/devices/example-user-mac2.yaml @@ -20,7 +20,8 @@ workspace_roots: materialization: default_mode: "absent" include: - - selector: "portfolio:personal-projects" + - match: + owner_login: "example-user" workspace_root: "projects" mode: "active" diff --git a/estate/devices/example-user-ubuntu-1.yaml b/estate/devices/example-user-ubuntu-1.yaml index e7285d4..0a7416e 100644 --- a/estate/devices/example-user-ubuntu-1.yaml +++ b/estate/devices/example-user-ubuntu-1.yaml @@ -21,7 +21,8 @@ workspace_roots: materialization: default_mode: "absent" include: - - selector: "portfolio:organization-projects" + - match: + owner_login: "example-org" workspace_root: "projects" mode: "active" diff --git a/estate/devices/example-workstation.yaml b/estate/devices/example-workstation.yaml index 2d33bcb..0e17113 100644 --- a/estate/devices/example-workstation.yaml +++ b/estate/devices/example-workstation.yaml @@ -17,14 +17,33 @@ device: workspace_roots: organization: "${HOME}/Developer/organization" personal: "${HOME}/Developer/personal" + servers: "${HOME}/Developer/servers" materialization: default_mode: "absent" + # Trait-matched placement: repositories land under a root by their provider + # facts, not by a label an anchor must carry. Order matters -- the first + # matching include wins, so `server-` repositories reach their own root + # before the generic owner rules apply. include: - - selector: "portfolio:organization-projects" + - match: + owner_login: "example-org" + name_prefixes: + - "server-" + workspace_root: "servers" + mode: "active" + - match: + owner_login: "example-user" + name_prefixes: + - "server-" + workspace_root: "servers" + mode: "active" + - match: + owner_login: "example-org" workspace_root: "organization" mode: "active" - - selector: "portfolio:personal-projects" + - match: + owner_login: "example-user" workspace_root: "personal" mode: "active" diff --git a/estate/owners/example-guild.yaml b/estate/owners/example-guild.yaml index 7f75897..4772f7f 100644 --- a/estate/owners/example-guild.yaml +++ b/estate/owners/example-guild.yaml @@ -10,4 +10,4 @@ defaults: rollout_ring: "standard" classification: - source_portfolio: "portfolio:organization-projects" + source_portfolio: "portfolio:example-guild" diff --git a/estate/owners/example-media.yaml b/estate/owners/example-media.yaml index e72eb9f..e3221a5 100644 --- a/estate/owners/example-media.yaml +++ b/estate/owners/example-media.yaml @@ -10,4 +10,4 @@ defaults: rollout_ring: "standard" classification: - source_portfolio: "portfolio:organization-projects" + source_portfolio: "portfolio:example-media" diff --git a/estate/owners/example-org.yaml b/estate/owners/example-org.yaml index 96f4d63..2cbefda 100644 --- a/estate/owners/example-org.yaml +++ b/estate/owners/example-org.yaml @@ -10,4 +10,4 @@ defaults: rollout_ring: "standard" classification: - source_portfolio: "portfolio:organization-projects" + source_portfolio: "portfolio:example-org" diff --git a/estate/owners/example-user.yaml b/estate/owners/example-user.yaml index 5618254..54595c1 100644 --- a/estate/owners/example-user.yaml +++ b/estate/owners/example-user.yaml @@ -10,4 +10,4 @@ defaults: rollout_ring: "standard" classification: - source_portfolio: "portfolio:personal-projects" + source_portfolio: "portfolio:example-user" diff --git a/estate/owners/opennetwork.yaml b/estate/owners/opennetwork.yaml index 00ced19..9205425 100644 --- a/estate/owners/opennetwork.yaml +++ b/estate/owners/opennetwork.yaml @@ -10,4 +10,4 @@ defaults: rollout_ring: "standard" classification: - source_portfolio: "portfolio:organization-projects" + source_portfolio: "portfolio:nddev-opennetwork" diff --git a/estate/selectors/guild-sources.yaml b/estate/selectors/guild-sources.yaml index a893123..4bf82ad 100644 --- a/estate/selectors/guild-sources.yaml +++ b/estate/selectors/guild-sources.yaml @@ -10,7 +10,7 @@ match: assign: management_mode: "observe-only" portfolios: - - "portfolio:organization-projects" + - "portfolio:example-guild" policy_profiles: - "repository-default" - "organization-default" diff --git a/estate/selectors/media-sources.yaml b/estate/selectors/media-sources.yaml index f9a5364..70f6bbb 100644 --- a/estate/selectors/media-sources.yaml +++ b/estate/selectors/media-sources.yaml @@ -10,7 +10,7 @@ match: assign: management_mode: "observe-only" portfolios: - - "portfolio:organization-projects" + - "portfolio:example-media" policy_profiles: - "repository-default" - "organization-default" diff --git a/estate/selectors/opennetwork-sources.yaml b/estate/selectors/opennetwork-sources.yaml index 0fe08a8..7426bae 100644 --- a/estate/selectors/opennetwork-sources.yaml +++ b/estate/selectors/opennetwork-sources.yaml @@ -10,7 +10,7 @@ match: assign: management_mode: "managed" portfolios: - - "portfolio:organization-projects" + - "portfolio:nddev-opennetwork" policy_profiles: - "repository-default" - "organization-default" diff --git a/estate/selectors/organization-servers.yaml b/estate/selectors/organization-servers.yaml index 1d9a071..461e7ae 100644 --- a/estate/selectors/organization-servers.yaml +++ b/estate/selectors/organization-servers.yaml @@ -15,7 +15,7 @@ match: assign: management_mode: "observe-only" portfolios: - - "portfolio:servers" + - "portfolio:example-org" policy_profiles: - "repository-default" - "servers-default" diff --git a/estate/selectors/organization-sources.yaml b/estate/selectors/organization-sources.yaml index feb9547..5170e70 100644 --- a/estate/selectors/organization-sources.yaml +++ b/estate/selectors/organization-sources.yaml @@ -13,7 +13,7 @@ match: assign: management_mode: "managed" portfolios: - - "portfolio:organization-projects" + - "portfolio:example-org" policy_profiles: - "repository-default" - "organization-default" diff --git a/estate/selectors/personal-servers.yaml b/estate/selectors/personal-servers.yaml index 5257bb7..335b7d2 100644 --- a/estate/selectors/personal-servers.yaml +++ b/estate/selectors/personal-servers.yaml @@ -15,7 +15,7 @@ match: assign: management_mode: "observe-only" portfolios: - - "portfolio:servers" + - "portfolio:example-user" policy_profiles: - "repository-default" - "servers-default" diff --git a/estate/selectors/personal-sources.yaml b/estate/selectors/personal-sources.yaml index cd82268..4ea4b88 100644 --- a/estate/selectors/personal-sources.yaml +++ b/estate/selectors/personal-sources.yaml @@ -13,7 +13,7 @@ match: assign: management_mode: "observe-only" portfolios: - - "portfolio:personal-projects" + - "portfolio:example-user" policy_profiles: - "repository-default" - "personal-default" diff --git a/policies/portfolios/servers-default.yaml b/policies/portfolios/servers-default.yaml index e675756..699cf64 100644 --- a/policies/portfolios/servers-default.yaml +++ b/policies/portfolios/servers-default.yaml @@ -1,8 +1,9 @@ schema_version: 1 -# A portfolio-tier policy. Portfolios cut across owners: a server repository is -# governed the same way whoever owns it, which is exactly what an owner tier -# cannot express. +# A cross-owner policy. A `server-` prefixed repository is governed the same +# way whoever owns it, which is exactly what an owner tier cannot express -- +# name prefixes reach across owner boundaries without inventing a grouping +# label. policy: id: "servers-default" tier: "portfolio" @@ -10,8 +11,8 @@ policy: distribution: "public" match: - portfolios: - - "portfolio:servers" + name_prefixes: + - "server-" apply: git: diff --git a/schemas/v1/device.schema.json b/schemas/v1/device.schema.json index ac64617..f73b106 100644 --- a/schemas/v1/device.schema.json +++ b/schemas/v1/device.schema.json @@ -130,14 +130,68 @@ "type": "object", "additionalProperties": false, "required": [ - "selector", "workspace_root", "mode" ], + "anyOf": [ + { + "required": [ + "selector" + ] + }, + { + "required": [ + "match" + ] + } + ], "properties": { "selector": { + "description": "Portfolio-style membership selector, matched against the repository anchor's classification.portfolios. Kept for compatibility; `match` is the trait-based form.", "$ref": "common.schema.json#/$defs/referenceId" }, + "match": { + "description": "Trait-based placement: evaluated against repository facts (provider owner login, repository name, visibility contract) rather than a label the anchor must carry. Includes are evaluated in order and the first match wins, so a specialized rule (for example `server-` prefixed repositories) belongs before the generic owner rule it narrows.", + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "owner_login": { + "description": "Exact provider owner login (GitHub account or organization name). Case-insensitive.", + "$ref": "common.schema.json#/$defs/githubOwner" + }, + "names": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[A-Za-z0-9._-]+$" + } + }, + "name_prefixes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[A-Za-z0-9._-]+$" + } + }, + "visibility": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "common.schema.json#/$defs/visibility" + } + } + } + }, "workspace_root": { "$ref": "common.schema.json#/$defs/kebabId" }, diff --git a/schemas/v1/policy.schema.json b/schemas/v1/policy.schema.json index d9ae005..e2b7bf5 100644 --- a/schemas/v1/policy.schema.json +++ b/schemas/v1/policy.schema.json @@ -59,6 +59,17 @@ "$ref": "common.schema.json#/$defs/referenceId" } }, + "name_prefixes": { + "description": "Match repositories by provider name prefix, case-insensitive. Lets a cross-owner policy reach e.g. every `server-` repository without inventing a portfolio.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[A-Za-z0-9._-]+$" + } + }, "visibility_contract": { "type": "array", "uniqueItems": true, diff --git a/scripts/validate_gds_schemas.py b/scripts/validate_gds_schemas.py index f6942bc..9c6d8fd 100755 --- a/scripts/validate_gds_schemas.py +++ b/scripts/validate_gds_schemas.py @@ -465,6 +465,15 @@ def _semantic_findings(schema_name: str, instance: Any, path: Path) -> list[Find continue selector = assignment.get("selector") workspace_root = assignment.get("workspace_root") + # Trait-matched includes key on canonical match content so several + # distinct match rules may share one device; identical rules are + # still duplicates. + if not isinstance(selector, str) and isinstance( + assignment.get("match"), Mapping + ): + selector = "match:" + json.dumps( + assignment["match"], sort_keys=True + ) if isinstance(selector, str): if selector in selectors: findings.append( @@ -489,7 +498,11 @@ def _semantic_findings(schema_name: str, instance: Any, path: Path) -> list[Find }, ) ) - if isinstance(workspace_root, str) and isinstance(selector, str): + # The one-root-one-selector rule covers label selectors only; trait + # matches are disjoint by construction or ordered by first match. + if isinstance(workspace_root, str) and isinstance( + assignment.get("selector"), str + ): previous_selector = used_roots.get(workspace_root) if previous_selector is not None and previous_selector != selector: findings.append( diff --git a/skills/canonical/gds-audit-estate/SKILL.md b/skills/canonical/gds-audit-estate/SKILL.md index c035548..4c668f4 100644 --- a/skills/canonical/gds-audit-estate/SKILL.md +++ b/skills/canonical/gds-audit-estate/SKILL.md @@ -69,7 +69,8 @@ priority-band convention: `100` for generic source/fork classification, `200` for specialized non-fork overrides (servers, named-prefix families), and `300` for state overrides that outrank topology and name (archived). The archived state takes precedence over fork topology and the `server-*` name, so a -provider-archived repository resolves to `portfolio:archived-projects` +provider-archived repository resolves to the archive tenancy +(`portfolio:`, e.g. `portfolio:example-archive`) regardless of its fork flag or name prefix. See `docs/contracts/estate-v1.md` for the full precedence rule. diff --git a/skills/canonical/gds-reconcile-provider-change/SKILL.md b/skills/canonical/gds-reconcile-provider-change/SKILL.md index 62d4fd0..b2811b3 100644 --- a/skills/canonical/gds-reconcile-provider-change/SKILL.md +++ b/skills/canonical/gds-reconcile-provider-change/SKILL.md @@ -44,9 +44,10 @@ required. 3. If the change moved the repository across owners or portfolios, re-resolve selector classification and confirm no `GDS_ESTATE_SELECTOR_CONFLICT` arises from the new owner/portfolio combination. -4. If the repository was archived, confirm it now resolves to - `portfolio:archived-projects` under the archived-precedence rule - (priority `300`), regardless of prior fork or server classification. +4. If the repository was archived, confirm it now resolves to the archive + owner's tenancy portfolio (e.g. `portfolio:example-archive`) under the + archived-precedence rule (priority `300`), regardless of prior fork or + server classification. 5. Regenerate projections with `gds generate repository --plan/--apply` and refresh Serena memory digests if any memory source file changed. 6. Verify provider and local final state. From 13218cb1a3f74545269ed76604b483613e51caaa Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Sat, 3 Oct 2026 15:59:21 +0500 Subject: [PATCH 2/4] chore(projections): restamp bundle for provider-account tenancy --- .gds/bundle.lock.yaml | 10 +++++----- .gds/repository.yaml | 2 +- .github/workflows/gds-ci.yml | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index 8da297e..b00a5c3 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -4,14 +4,14 @@ schema_version: 1 bundle: version: "0.9.7-dev" release_sequence: 0 - source_tree_digest: "sha256:f57aabc1e0980ecfd7b40d7728812a9c03af9f844c2b9ef7f8765b7720f29952" - digest: "sha256:d7e3c5193688aece7f043ee30612f68992af8af3c8fae18e035a6d8050903cc5" + source_tree_digest: "sha256:c8a292cd256ce51798eff5c2f72ec3e29ab3129475f7f9e2eb10eb59acde578e" + digest: "sha256:c571abdba49aa429377135bebed0a3f5831ab03561a2a2b56029c3af4e0705a9" projection: - input_digest: "sha256:902dc4eb06c1608f4f3303bdead643efc1d540d578f65a3d6f65408dbd92355f" - output_digest: "sha256:3bc62bba5a5405bedb483ffa1e03dc74ff22991ba153035a41306f53f368c3ed" + input_digest: "sha256:98e3bf1d69e57743eaae7100263c3500d39cd81ed3265fbc95098ce6017210df" + output_digest: "sha256:7ac07e92e0168ac001e6909426596a113e0012c63b1f404bf5f2d9eedf4e13b3" files: - path: ".gds/compiled-policy.json" digest: "sha256:9f498788bdc34e52a0ab793c536e0e6a7b360c2e1a20446cbf03ed51986cdc6f" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:d5fe07fdc246ebe55b9c243db242342fb6f09b4c265a0d26a89838be33c7a7a5" + digest: "sha256:af0058a1bfef5e1e9a2e1e5f8b56e3c0d971ed4c1415f4ec53ea5e9e68907a0b" diff --git a/.gds/repository.yaml b/.gds/repository.yaml index 81836ce..517faa1 100644 --- a/.gds/repository.yaml +++ b/.gds/repository.yaml @@ -22,7 +22,7 @@ provider: classification: portfolios: - - "portfolio:opennetwork-projects" + - "portfolio:nddev-opennetwork" visibility_contract: "public" data_classification: "public" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index 671b77c..59c86fb 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.9.7-dev -# source-tree-digest: sha256:f57aabc1e0980ecfd7b40d7728812a9c03af9f844c2b9ef7f8765b7720f29952 -# input-digest: sha256:902dc4eb06c1608f4f3303bdead643efc1d540d578f65a3d6f65408dbd92355f +# source-tree-digest: sha256:c8a292cd256ce51798eff5c2f72ec3e29ab3129475f7f9e2eb10eb59acde578e +# input-digest: sha256:98e3bf1d69e57743eaae7100263c3500d39cd81ed3265fbc95098ce6017210df # output-digest: sha256:4ef1ee2fcc42927eaedef9c85f7b421f87e5cc75ff7055ef520216a00f7d4b74 # edit-source: # - .gds/repository.yaml From 19f34bb3861a7524b1521296e2df00220af655b3 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Sat, 3 Oct 2026 16:00:29 +0500 Subject: [PATCH 3/4] chore(projections): restamp lock against committed tenancy sources --- .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 b00a5c3..abe1a65 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -4,14 +4,14 @@ schema_version: 1 bundle: version: "0.9.7-dev" release_sequence: 0 - source_tree_digest: "sha256:c8a292cd256ce51798eff5c2f72ec3e29ab3129475f7f9e2eb10eb59acde578e" - digest: "sha256:c571abdba49aa429377135bebed0a3f5831ab03561a2a2b56029c3af4e0705a9" + source_tree_digest: "sha256:ba259a2260cfc55a79b76c69ea895ab446ea9ae6fe2892a016688713f903e162" + digest: "sha256:9b2dd4f5e413021932c2454c7e482fd790482ec9246e5adaf783dc92780672d9" projection: - input_digest: "sha256:98e3bf1d69e57743eaae7100263c3500d39cd81ed3265fbc95098ce6017210df" - output_digest: "sha256:7ac07e92e0168ac001e6909426596a113e0012c63b1f404bf5f2d9eedf4e13b3" + input_digest: "sha256:4145cec9f3f37a5c3930907936c97ebab0dd5d3e96a3ee480220aa32255c2ffe" + output_digest: "sha256:2572b8c1c82ccf29b5f7950c788f29cc5504bebf64b141e6b60b743d65366717" files: - path: ".gds/compiled-policy.json" digest: "sha256:9f498788bdc34e52a0ab793c536e0e6a7b360c2e1a20446cbf03ed51986cdc6f" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:af0058a1bfef5e1e9a2e1e5f8b56e3c0d971ed4c1415f4ec53ea5e9e68907a0b" + digest: "sha256:439aac0176476d26063ff3799233a79a1032b905b737aa1b1db56d12f5c9b658" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index 59c86fb..7b34393 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.9.7-dev -# source-tree-digest: sha256:c8a292cd256ce51798eff5c2f72ec3e29ab3129475f7f9e2eb10eb59acde578e -# input-digest: sha256:98e3bf1d69e57743eaae7100263c3500d39cd81ed3265fbc95098ce6017210df +# source-tree-digest: sha256:ba259a2260cfc55a79b76c69ea895ab446ea9ae6fe2892a016688713f903e162 +# input-digest: sha256:4145cec9f3f37a5c3930907936c97ebab0dd5d3e96a3ee480220aa32255c2ffe # output-digest: sha256:4ef1ee2fcc42927eaedef9c85f7b421f87e5cc75ff7055ef520216a00f7d4b74 # edit-source: # - .gds/repository.yaml From 3f80cb97e7a7bc25f7cdafb76ce6b8891b21f551 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Sat, 3 Oct 2026 16:01:53 +0500 Subject: [PATCH 4/4] chore(projections): regenerate control-plane goldens for tenancy sources --- .../golden/projections/control-plane/.claude/CLAUDE.md | 2 +- .../projections/control-plane/.gds/bundle.lock.yaml | 10 +++++----- .../control-plane/.github/workflows/gds-ci.yml | 2 +- tests/golden/projections/control-plane/AGENTS.md | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/golden/projections/control-plane/.claude/CLAUDE.md b/tests/golden/projections/control-plane/.claude/CLAUDE.md index de8bf12..317afa2 100644 --- a/tests/golden/projections/control-plane/.claude/CLAUDE.md +++ b/tests/golden/projections/control-plane/.claude/CLAUDE.md @@ -3,7 +3,7 @@ GENERATED FILE - DO NOT EDIT DIRECTLY generator: gds bundle: 0.9.7-dev source-tree-digest: sha256:0000000000000000000000000000000000000000000000000000000000000001 -input-digest: sha256:f419906bd39c940ce28a06555363d1cff2da2436c72ecba6962d5efdc0005fe0 +input-digest: sha256:da32315657c5ccdce25e41824c3595948dddc2bada5274ce3a66c050fe5a334b output-digest: sha256:88cb57297d8d713287872a8afaca8d42f7146ecf7a091e4996e65eee8f962665 edit-source: - .gds/repository.yaml diff --git a/tests/golden/projections/control-plane/.gds/bundle.lock.yaml b/tests/golden/projections/control-plane/.gds/bundle.lock.yaml index 8873c6e..ea1b0c8 100644 --- a/tests/golden/projections/control-plane/.gds/bundle.lock.yaml +++ b/tests/golden/projections/control-plane/.gds/bundle.lock.yaml @@ -8,14 +8,14 @@ bundle: digest: "sha256:fe686e5956e8cd0e9904ebdbf70a5abd14998e3ae04993781fcb93e48a09e6ab" projection: - input_digest: "sha256:f419906bd39c940ce28a06555363d1cff2da2436c72ecba6962d5efdc0005fe0" - output_digest: "sha256:5d27eca3a0013bdd168f55e8f708347ca14298db8011f114f5c93e3a0d1a1c6d" + input_digest: "sha256:da32315657c5ccdce25e41824c3595948dddc2bada5274ce3a66c050fe5a334b" + output_digest: "sha256:2ba5e1c9d0039cbb14e37dd7ffd16fbeb5300d53589313271c84fb573da82e7f" files: - path: ".claude/CLAUDE.md" - digest: "sha256:3c4bd8794fa9d2b70b439e1517d90ecd8257c0cae930f1572bf6ca83bf386306" + digest: "sha256:ec9db91505606f324044cbc0b6b2d94d4d73ac7a9d4806f6b252ff2933c14a10" - path: ".gds/compiled-policy.json" digest: "sha256:f86f7e2eb77664de1960f9dd25835b4e7341ce02378803df34372c50c94c86fb" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:337e431877ae9911c32b1e60a47206cb9340d5e45bd708b02efe396612f7fb0c" + digest: "sha256:b3a8828d52be668d2101c518e73f7e38e70160446352c0a5b8f097f79ef42ec5" - path: "AGENTS.md" - digest: "sha256:3633c51fdc2d7aeec3d161fa33c0eaa317f14402309ba4ea9aa5a15057620001" + digest: "sha256:febe4b319a535ee8234d6496b8662088ac8f9c047b2418369a90ce5a2886b87c" diff --git a/tests/golden/projections/control-plane/.github/workflows/gds-ci.yml b/tests/golden/projections/control-plane/.github/workflows/gds-ci.yml index 6d67247..38f57cd 100644 --- a/tests/golden/projections/control-plane/.github/workflows/gds-ci.yml +++ b/tests/golden/projections/control-plane/.github/workflows/gds-ci.yml @@ -2,7 +2,7 @@ # generator: gds # bundle: 0.9.7-dev # source-tree-digest: sha256:0000000000000000000000000000000000000000000000000000000000000001 -# input-digest: sha256:f419906bd39c940ce28a06555363d1cff2da2436c72ecba6962d5efdc0005fe0 +# input-digest: sha256:da32315657c5ccdce25e41824c3595948dddc2bada5274ce3a66c050fe5a334b # output-digest: sha256:4ef1ee2fcc42927eaedef9c85f7b421f87e5cc75ff7055ef520216a00f7d4b74 # edit-source: # - .gds/repository.yaml diff --git a/tests/golden/projections/control-plane/AGENTS.md b/tests/golden/projections/control-plane/AGENTS.md index d10efd0..c7d5b7e 100644 --- a/tests/golden/projections/control-plane/AGENTS.md +++ b/tests/golden/projections/control-plane/AGENTS.md @@ -3,7 +3,7 @@ GENERATED FILE - DO NOT EDIT DIRECTLY generator: gds bundle: 0.9.7-dev source-tree-digest: sha256:0000000000000000000000000000000000000000000000000000000000000001 -input-digest: sha256:f419906bd39c940ce28a06555363d1cff2da2436c72ecba6962d5efdc0005fe0 +input-digest: sha256:da32315657c5ccdce25e41824c3595948dddc2bada5274ce3a66c050fe5a334b output-digest: sha256:c9674389b139e2ea844844d3e0bb9227a1528f4a5af9347e5dd43c563f8e1bd4 edit-source: - .gds/repository.yaml