From d3af466e327a487b82d70aa752cbb4de1a951806 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Sat, 3 Oct 2026 16:17:47 +0500 Subject: [PATCH] feat: lifecycle field in trait-based placement match Extends materialization include[].match with lifecycle so device rules can keep archived repositories out of workspaces without a purpose portfolio. --- core/workspace/device.go | 5 ++++ core/workspace/device_test.go | 30 +++++++++++++++++++ .../0040-tenancy-is-the-provider-account.md | 9 +++--- docs/contracts/estate-v1.md | 2 +- schemas/v1/device.schema.json | 9 ++++++ 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/core/workspace/device.go b/core/workspace/device.go index 4488f4e..f8a7290 100644 --- a/core/workspace/device.go +++ b/core/workspace/device.go @@ -99,6 +99,7 @@ type PlacementMatch struct { Names []string `json:"names,omitempty"` NamePrefixes []string `json:"name_prefixes,omitempty"` Visibility []string `json:"visibility,omitempty"` + Lifecycle []string `json:"lifecycle,omitempty"` } func (match PlacementMatch) satisfiedBy(anchor domain.RepositoryAnchor) bool { @@ -136,6 +137,10 @@ func (match PlacementMatch) satisfiedBy(anchor domain.RepositoryAnchor) bool { !contains(match.Visibility, anchor.Classification.VisibilityContract) { return false } + if len(match.Lifecycle) != 0 && + !contains(match.Lifecycle, anchor.Repository.Lifecycle) { + return false + } return true } diff --git a/core/workspace/device_test.go b/core/workspace/device_test.go index 1cd708e..b35847e 100644 --- a/core/workspace/device_test.go +++ b/core/workspace/device_test.go @@ -82,6 +82,36 @@ func TestResolvePlacementTraitMatch(t *testing.T) { } } +func TestResolvePlacementLifecycleMatch(t *testing.T) { + descriptor := testDevice() + descriptor.Materialization.Include = []MaterializationAssignment{{ + Match: &PlacementMatch{ + OwnerLogin: "example-org", + Lifecycle: []string{"active", "maintenance"}, + }, + WorkspaceRoot: "personal", Mode: "active", + }} + anchor := testWorkspaceAnchor() + anchor.Provider.Owner = "example-org" + anchor.Repository.Lifecycle = "archived" + home := filepath.Join(string(filepath.Separator), "home", "owner") + placement, findings := ResolvePlacement(descriptor, anchor, Environment{ + Home: home, XDGStateHome: filepath.Join(home, ".local", "state"), + }) + if placement.Mode != "absent" || len(findings) != 1 || + findings[0].Code != "GDS_WORKSPACE_PLACEMENT_NOT_SELECTED" { + t.Fatalf("placement=%#v findings=%#v", placement, findings) + } + + anchor.Repository.Lifecycle = "active" + 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 { return DeviceDescriptor{ SchemaVersion: 1, diff --git a/docs/adr/0040-tenancy-is-the-provider-account.md b/docs/adr/0040-tenancy-is-the-provider-account.md index 65c5240..e103012 100644 --- a/docs/adr/0040-tenancy-is-the-provider-account.md +++ b/docs/adr/0040-tenancy-is-the-provider-account.md @@ -34,13 +34,14 @@ That coupling has real costs: **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 +are tenant names (`portfolio:example-org`, `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; + labels.** `match.owner_login`, `match.names`, `match.name_prefixes`, + `match.visibility` and `match.lifecycle` are evaluated against the + repository's provider identity, visibility contract and declared + lifecycle. 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 diff --git a/docs/contracts/estate-v1.md b/docs/contracts/estate-v1.md index 5533e6d..72a0418 100644 --- a/docs/contracts/estate-v1.md +++ b/docs/contracts/estate-v1.md @@ -78,7 +78,7 @@ 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 +(owner login, exact names, name prefixes, visibility contract, lifecycle) 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. diff --git a/schemas/v1/device.schema.json b/schemas/v1/device.schema.json index f73b106..1dc7829 100644 --- a/schemas/v1/device.schema.json +++ b/schemas/v1/device.schema.json @@ -189,6 +189,15 @@ "items": { "$ref": "common.schema.json#/$defs/visibility" } + }, + "lifecycle": { + "description": "Match repositories by declared lifecycle (for example keep `archived` repositories out of device workspaces).", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "common.schema.json#/$defs/lifecycle" + } } } },