# Server Tags Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Give every server a `key:value` tag map, let workflows target servers by tag selector unioned with their explicit list, and surface both in the UI.

**Architecture:** Tags are a `map[string]string` field on the existing `servers` documents — no new collection. Validation and matching live as pure functions in `server/internal/services` so they can be unit tested without a database; the Mongo-backed wrappers around them stay thin. Workflow targeting gains `target_tags` beside the untouched `target_server_ids`, and one `ResolveTargets` function produces the distinct union that `TriggerWorkflow` already expects as a list of server IDs.

**Tech Stack:** Go 1.26, gin, mongo-driver v2, Next.js 16 + TanStack Query, Tailwind 3.

## Global Constraints

- Tag keys and values are lowercase `[a-z0-9_-]` only. Key max 32 chars, value max 64 chars, max 20 tags per server.
- Reserved key prefix `sys:` — rejected on user writes, unused otherwise.
- Every mutating API path writes an audit event via `services.LogEvent`.
- Every service query is scoped by `instance_id`. No exceptions, no unscoped lookups.
- No component in `web/` may carry a hex colour; use the Tailwind token names only.
- `web/` is dark-only. Do not add a light theme.
- This repository has **no Go tests today**. Tests added here are the first; they must run under plain `go test ./...` from the repo root with no database, no network, and no build tags.
- Commit messages use the `feat:` / `fix:` / `docs:` prefixes already in `git log`.

---

## File Structure

**Create:**
- `server/internal/services/tags.go` — tag validation, normalisation, matching. Pure functions plus the Mongo-backed read/write.
- `server/internal/services/tags_test.go` — unit tests for the pure half.
- `server/internal/services/targets.go` — `ResolveTargets` and its pure core.
- `server/internal/services/targets_test.go` — unit tests for the pure core.
- `web/components/servers/TagChips.tsx` — display and inline edit of one server's tags.
- `web/components/servers/TagFilterBar.tsx` — the fleet list filter.

**Modify:**
- `server/internal/models/server.go` — add `Tags`.
- `server/internal/models/workflow.go` — add `TargetTags`.
- `server/internal/services/servers.go` — `ListServers` gains a tag filter; add `EnsureServerIndexes`.
- `server/internal/services/workflow_runner.go:20-34` — `TriggerWorkflow` resolves targets.
- `server/internal/services/workflows.go` — validate `target_tags` on create/update.
- `server/internal/api/handlers.go` — three routes and their handlers.
- `server/cmd/main.go:111` — call `EnsureServerIndexes`.
- `web/lib/api.ts` — types and client methods.
- `web/app/(app)/servers/page.tsx` — filter bar and tag column.
- `web/app/(app)/servers/[id]/page.tsx` — tag chips in the header.
- `web/app/(app)/workflows/[id]/page.tsx` — target section.
- `docsite/docs/vantage/servers.md`, `docsite/docs/vantage/workflows.md` — document tags.
- `CLAUDE.md` — one line in the servers/workflows subsystem notes.

---

### Task 1: Tag validation

**Files:**
- Create: `server/internal/services/tags.go`
- Test: `server/internal/services/tags_test.go`
- Modify: `server/internal/models/server.go:46-67`

**Interfaces:**
- Consumes: nothing.
- Produces: `services.ValidateTags(tags map[string]string) error`, `services.ErrInvalidTag` (a `error` value), and `models.Server.Tags map[string]string`.

- [ ] **Step 1: Write the failing test**

Create `server/internal/services/tags_test.go`:

```go
package services

import (
	"strings"
	"testing"
)

func TestValidateTags(t *testing.T) {
	long33 := strings.Repeat("a", 33)
	long65 := strings.Repeat("b", 65)

	tooMany := map[string]string{}
	for i := 0; i < 21; i++ {
		tooMany[string(rune('a'+i))] = "x"
	}

	cases := []struct {
		name string
		tags map[string]string
		ok   bool
	}{
		{"empty is fine", map[string]string{}, true},
		{"nil is fine", nil, true},
		{"simple pair", map[string]string{"env": "prod"}, true},
		{"dash and underscore", map[string]string{"team_name": "core-infra"}, true},
		{"digits", map[string]string{"tier1": "web2"}, true},
		{"uppercase key", map[string]string{"Env": "prod"}, false},
		{"uppercase value", map[string]string{"env": "Prod"}, false},
		{"space in value", map[string]string{"env": "pro d"}, false},
		{"colon in key", map[string]string{"en:v": "prod"}, false},
		{"empty key", map[string]string{"": "prod"}, false},
		{"empty value", map[string]string{"env": ""}, false},
		{"key too long", map[string]string{long33: "prod"}, false},
		{"value too long", map[string]string{"env": long65}, false},
		{"reserved prefix", map[string]string{"sys:os": "linux"}, false},
		{"too many tags", tooMany, false},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			err := ValidateTags(tc.tags)
			if tc.ok && err != nil {
				t.Fatalf("expected valid, got %v", err)
			}
			if !tc.ok && err == nil {
				t.Fatal("expected an error, got nil")
			}
		})
	}
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd server && go test ./internal/services/ -run TestValidateTags -v`
Expected: FAIL — `undefined: ValidateTags`

- [ ] **Step 3: Write minimal implementation**

Create `server/internal/services/tags.go`:

```go
package services

import (
	"errors"
	"fmt"
	"strings"
)

// ErrInvalidTag is returned for any tag the rules below reject. Handlers map
// it to 400 — a malformed tag is the caller's mistake, not a server fault.
var ErrInvalidTag = errors.New("invalid tag")

const (
	maxTagKeyLen   = 32
	maxTagValueLen = 64
	maxTagsPerHost = 20
	// Reserved for tags the agent may derive from inventory later. Refusing
	// it now means a user tag written today can never collide with a system
	// tag invented tomorrow.
	sysTagPrefix = "sys:"
)

func validTagRunes(s string) bool {
	for _, r := range s {
		switch {
		case r >= 'a' && r <= 'z':
		case r >= '0' && r <= '9':
		case r == '-' || r == '_':
		default:
			return false
		}
	}
	return true
}

// ValidateTags enforces the shape of a whole tag map. It lives in the service
// layer rather than a handler so that every write path — the tags endpoint,
// server create, anything added later — agrees on what a valid tag is.
func ValidateTags(tags map[string]string) error {
	if len(tags) > maxTagsPerHost {
		return fmt.Errorf("%w: at most %d tags per server", ErrInvalidTag, maxTagsPerHost)
	}
	for k, v := range tags {
		if strings.HasPrefix(k, sysTagPrefix) {
			return fmt.Errorf("%w: keys beginning %q are reserved", ErrInvalidTag, sysTagPrefix)
		}
		if k == "" || len(k) > maxTagKeyLen || !validTagRunes(k) {
			return fmt.Errorf("%w: key %q must be 1-%d chars of a-z, 0-9, - or _", ErrInvalidTag, k, maxTagKeyLen)
		}
		if v == "" || len(v) > maxTagValueLen || !validTagRunes(v) {
			return fmt.Errorf("%w: value for %q must be 1-%d chars of a-z, 0-9, - or _", ErrInvalidTag, k, maxTagValueLen)
		}
	}
	return nil
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd server && go test ./internal/services/ -run TestValidateTags -v`
Expected: PASS, all 15 subtests.

- [ ] **Step 5: Add the model field**

In `server/internal/models/server.go`, inside `type Server struct`, after the `Inventory` line:

```go
	Tags             map[string]string `bson:"tags,omitempty" json:"tags,omitempty"`
```

- [ ] **Step 6: Verify it builds**

Run: `cd server && go build ./...`
Expected: no output.

- [ ] **Step 7: Commit**

```bash
git add server/internal/services/tags.go server/internal/services/tags_test.go server/internal/models/server.go
git commit -m "feat: validate server tags and add the model field"
```

---

### Task 2: Tag parsing for query strings

**Files:**
- Modify: `server/internal/services/tags.go`
- Test: `server/internal/services/tags_test.go`

**Interfaces:**
- Consumes: `ValidateTags`, `ErrInvalidTag` from Task 1.
- Produces: `services.ParseTagFilters(raw []string) (map[string]string, error)`.

- [ ] **Step 1: Write the failing test**

Append to `server/internal/services/tags_test.go`:

```go
func TestParseTagFilters(t *testing.T) {
	got, err := ParseTagFilters([]string{"env:prod", "role:web"})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(got) != 2 || got["env"] != "prod" || got["role"] != "web" {
		t.Fatalf("unexpected map: %#v", got)
	}

	empty, err := ParseTagFilters(nil)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(empty) != 0 {
		t.Fatalf("expected empty map, got %#v", empty)
	}

	for _, bad := range []string{"env", "env:", ":prod", "env:prod:extra", "ENV:prod", ""} {
		if _, err := ParseTagFilters([]string{bad}); err == nil {
			t.Fatalf("expected %q to be rejected", bad)
		}
	}
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd server && go test ./internal/services/ -run TestParseTagFilters -v`
Expected: FAIL — `undefined: ParseTagFilters`

- [ ] **Step 3: Write minimal implementation**

Append to `server/internal/services/tags.go`:

```go
// ParseTagFilters turns repeated ?tag=key:value query values into a map.
//
// A malformed filter is an error rather than a silently ignored value: a
// filter that matches nothing and a filter that is nonsense look identical in
// a list, and only one of them is the caller's fault.
func ParseTagFilters(raw []string) (map[string]string, error) {
	out := make(map[string]string, len(raw))
	for _, r := range raw {
		k, v, found := strings.Cut(r, ":")
		if !found {
			return nil, fmt.Errorf("%w: filter %q must be key:value", ErrInvalidTag, r)
		}
		if strings.Contains(v, ":") {
			return nil, fmt.Errorf("%w: filter %q has more than one colon", ErrInvalidTag, r)
		}
		out[k] = v
	}
	if err := ValidateTags(out); err != nil {
		return nil, err
	}
	return out, nil
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd server && go test ./internal/services/ -run TestParseTagFilters -v`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add server/internal/services/tags.go server/internal/services/tags_test.go
git commit -m "feat: parse repeated tag query filters"
```

---

### Task 3: Target resolution core

**Files:**
- Create: `server/internal/services/targets.go`
- Test: `server/internal/services/targets_test.go`

**Interfaces:**
- Consumes: `models.Server` with `Tags` from Task 1.
- Produces: `services.MatchesTags(srv models.Server, sel map[string]string) bool` and `services.UnionTargets(all []models.Server, ids []string, sel map[string]string) []models.Server`, plus `services.ErrNoTargets`.

- [ ] **Step 1: Write the failing test**

Create `server/internal/services/targets_test.go`:

```go
package services

import (
	"testing"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)

func fixtureServers() []models.Server {
	return []models.Server{
		{ServerID: "a", Hostname: "web-1", Tags: map[string]string{"env": "prod", "role": "web"}},
		{ServerID: "b", Hostname: "web-2", Tags: map[string]string{"env": "prod", "role": "web"}},
		{ServerID: "c", Hostname: "db-1", Tags: map[string]string{"env": "prod", "role": "db"}},
		{ServerID: "d", Hostname: "web-3", Tags: map[string]string{"env": "staging", "role": "web"}},
		{ServerID: "e", Hostname: "untagged-1"},
	}
}

func ids(servers []models.Server) []string {
	out := make([]string, 0, len(servers))
	for _, s := range servers {
		out = append(out, s.ServerID)
	}
	return out
}

func equalIDs(t *testing.T, got []models.Server, want ...string) {
	t.Helper()
	gotIDs := ids(got)
	if len(gotIDs) != len(want) {
		t.Fatalf("got %v, want %v", gotIDs, want)
	}
	for i := range want {
		if gotIDs[i] != want[i] {
			t.Fatalf("got %v, want %v", gotIDs, want)
		}
	}
}

func TestMatchesTags(t *testing.T) {
	web1 := fixtureServers()[0]

	if !MatchesTags(web1, map[string]string{"env": "prod"}) {
		t.Fatal("expected single-key match")
	}
	if !MatchesTags(web1, map[string]string{"env": "prod", "role": "web"}) {
		t.Fatal("expected AND across keys to match")
	}
	if MatchesTags(web1, map[string]string{"env": "prod", "role": "db"}) {
		t.Fatal("AND across keys must not match on one key alone")
	}
	if MatchesTags(web1, map[string]string{"team": "core"}) {
		t.Fatal("absent key must not match")
	}
	if MatchesTags(web1, map[string]string{}) {
		t.Fatal("an empty selector must match nothing, not everything")
	}
}

func TestUnionTargets(t *testing.T) {
	all := fixtureServers()

	t.Run("ids only", func(t *testing.T) {
		equalIDs(t, UnionTargets(all, []string{"a", "c"}, nil), "a", "c")
	})

	t.Run("selector only", func(t *testing.T) {
		equalIDs(t, UnionTargets(all, nil, map[string]string{"env": "prod", "role": "web"}), "a", "b")
	})

	t.Run("union deduplicates", func(t *testing.T) {
		// "a" is named explicitly AND matched by the selector; it appears once.
		equalIDs(t, UnionTargets(all, []string{"a", "c"}, map[string]string{"role": "web"}), "a", "b", "c", "d")
	})

	t.Run("unknown id is dropped", func(t *testing.T) {
		equalIDs(t, UnionTargets(all, []string{"a", "does-not-exist"}, nil), "a")
	})

	t.Run("both empty yields nothing", func(t *testing.T) {
		equalIDs(t, UnionTargets(all, nil, nil))
	})

	t.Run("order follows the fleet, not the arguments", func(t *testing.T) {
		equalIDs(t, UnionTargets(all, []string{"c", "a"}, nil), "a", "c")
	})
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd server && go test ./internal/services/ -run 'TestMatchesTags|TestUnionTargets' -v`
Expected: FAIL — `undefined: MatchesTags`

- [ ] **Step 3: Write minimal implementation**

Create `server/internal/services/targets.go`:

```go
package services

import (
	"errors"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)

// ErrNoTargets means a workflow named no servers and matched none. Handlers
// map it to 400: a workflow that matches nothing must say so rather than
// report success over zero servers.
var ErrNoTargets = errors.New("workflow has no target servers")

// MatchesTags reports whether srv carries every pair in sel — AND across keys.
// An empty selector matches nothing. That is deliberate: the alternative,
// "matches everything", turns a cleared field in the workflow designer into a
// fleet-wide run.
func MatchesTags(srv models.Server, sel map[string]string) bool {
	if len(sel) == 0 {
		return false
	}
	for k, v := range sel {
		if srv.Tags[k] != v {
			return false
		}
	}
	return true
}

// UnionTargets returns the distinct union of the servers named by ids and
// those matching sel, in the order they appear in all.
//
// Order comes from the fleet rather than the arguments so that two workflows
// naming the same servers in a different order still run them in the same
// order, which makes two runs comparable line by line.
//
// Offline servers are NOT filtered out. The dispatcher already answers 503 per
// server, and a patch run that silently omits an unreachable machine is worse
// than one that visibly fails on it.
func UnionTargets(all []models.Server, ids []string, sel map[string]string) []models.Server {
	named := make(map[string]bool, len(ids))
	for _, id := range ids {
		named[id] = true
	}

	out := make([]models.Server, 0, len(ids)+len(all))
	for _, s := range all {
		if named[s.ServerID] || MatchesTags(s, sel) {
			out = append(out, s)
		}
	}
	return out
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd server && go test ./internal/services/ -run 'TestMatchesTags|TestUnionTargets' -v`
Expected: PASS, all subtests.

- [ ] **Step 5: Commit**

```bash
git add server/internal/services/targets.go server/internal/services/targets_test.go
git commit -m "feat: resolve workflow targets as the union of ids and a tag selector"
```

---

### Task 4: Database-backed tag reads and writes

**Files:**
- Modify: `server/internal/services/tags.go`, `server/internal/services/servers.go`, `server/internal/services/targets.go`
- Modify: `server/cmd/main.go:111`

**Interfaces:**
- Consumes: `ValidateTags`, `UnionTargets`, `ErrNoTargets`.
- Produces:
  - `services.SetServerTags(instanceID, serverID string, tags map[string]string) error`
  - `services.KnownTags(instanceID string) (map[string][]string, error)`
  - `services.ListServersFiltered(instanceID string, sel map[string]string) ([]models.Server, error)`
  - `services.ResolveTargets(instanceID string, ids []string, sel map[string]string) ([]models.Server, error)`
  - `services.EnsureServerIndexes() error`

- [ ] **Step 1: Add the Mongo-backed tag functions**

Append to `server/internal/services/tags.go`:

```go
// SetServerTags replaces a server's whole tag map.
//
// Replace rather than patch: a tag set is small enough that sending all of it
// is free, and last-write-wins over a whole map is easier to reason about than
// merge semantics between two people editing the same server.
func SetServerTags(instanceID, serverID string, tags map[string]string) error {
	if err := ValidateTags(tags); err != nil {
		return err
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	res, err := db.Col("servers").UpdateOne(ctx,
		bson.M{"server_id": serverID, "instance_id": instanceID},
		bson.M{"$set": bson.M{"tags": tags}},
	)
	if err != nil {
		return err
	}
	if res.MatchedCount == 0 {
		return mongo.ErrNoDocuments
	}
	return nil
}

// KnownTags returns every key in use in this instance with its distinct
// values, for the UI's pickers. This is an aggregation rather than a
// maintained registry: a tag is a property of a server, not an entity, and a
// registry would need reference counting to know when a tag stopped existing.
func KnownTags(instanceID string) (map[string][]string, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	cur, err := db.Col("servers").Find(ctx,
		bson.M{"instance_id": instanceID, "tags": bson.M{"$exists": true}},
		options.Find().SetProjection(bson.M{"tags": 1}),
	)
	if err != nil {
		return nil, err
	}
	defer cur.Close(ctx)

	seen := map[string]map[string]bool{}
	for cur.Next(ctx) {
		var s models.Server
		if err := cur.Decode(&s); err != nil {
			return nil, err
		}
		for k, v := range s.Tags {
			if seen[k] == nil {
				seen[k] = map[string]bool{}
			}
			seen[k][v] = true
		}
	}
	if err := cur.Err(); err != nil {
		return nil, err
	}

	out := make(map[string][]string, len(seen))
	for k, vals := range seen {
		list := make([]string, 0, len(vals))
		for v := range vals {
			list = append(list, v)
		}
		sort.Strings(list)
		out[k] = list
	}
	return out, nil
}
```

Add to that file's imports: `"context"`, `"sort"`, `"time"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"`, `"go.mongodb.org/mongo-driver/v2/bson"`, `"go.mongodb.org/mongo-driver/v2/mongo"`, `"go.mongodb.org/mongo-driver/v2/mongo/options"`.

- [ ] **Step 2: Add the filtered list and the index builder**

Append to `server/internal/services/servers.go`:

```go
// ListServersFiltered is ListServers with an optional tag selector. An empty
// selector returns the whole fleet — unlike MatchesTags, where empty means
// "nothing", because here the caller is a list view whose default is
// "everything", not a run about to touch machines.
func ListServersFiltered(instanceID string, sel map[string]string) ([]models.Server, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	filter := bson.M{"instance_id": instanceID}
	for k, v := range sel {
		filter["tags."+k] = v
	}

	cur, err := db.Col("servers").Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "hostname", Value: 1}}))
	if err != nil {
		return nil, err
	}
	defer cur.Close(ctx)

	servers := []models.Server{}
	if err := cur.All(ctx, &servers); err != nil {
		return nil, err
	}
	return servers, nil
}

// EnsureServerIndexes declares the wildcard index over the tag subdocument.
// It is wildcard because the queried key is chosen by the user at request time
// and cannot be named in advance.
//
// Non-fatal, following EnsureSecretIndexes: a missing index degrades tag
// filtering to a collection scan over a small collection, which is slower.
// A fatal error here would refuse to boot the fleet list over it.
func EnsureServerIndexes() error {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	_, err := db.Col("servers").Indexes().CreateOne(ctx, mongo.IndexModel{
		Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "tags.$**", Value: 1}},
	})
	return err
}
```

Add `"go.mongodb.org/mongo-driver/v2/mongo"` to that file's imports if it is not already there.

- [ ] **Step 3: Add ResolveTargets**

Append to `server/internal/services/targets.go`:

```go
// ResolveTargets is the database-backed wrapper around UnionTargets. It is the
// single answer to "which servers does this workflow touch", used by the run
// path and by validation alike, so the two cannot disagree.
func ResolveTargets(instanceID string, ids []string, sel map[string]string) ([]models.Server, error) {
	all, err := ListServers(instanceID)
	if err != nil {
		return nil, err
	}
	matched := UnionTargets(all, ids, sel)
	if len(matched) == 0 {
		return nil, ErrNoTargets
	}
	return matched, nil
}
```

Add `"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"` to that file's imports (already present from Task 3).

- [ ] **Step 4: Wire the index builder into boot**

In `server/cmd/main.go`, beside the existing `EnsureSecretIndexes` call around line 111:

```go
	if err := services.EnsureServerIndexes(); err != nil {
		log.Printf("server index warning: %v", err)
	}
```

- [ ] **Step 5: Verify it builds and existing tests still pass**

Run: `cd server && go build ./... && go test ./...`
Expected: build silent, tests PASS.

- [ ] **Step 6: Commit**

```bash
git add server/internal/services/tags.go server/internal/services/servers.go server/internal/services/targets.go server/cmd/main.go
git commit -m "feat: read and write server tags, resolve targets from the database"
```

---

### Task 5: Tag API routes

**Files:**
- Modify: `server/internal/api/handlers.go` — routes at line 53-61, handlers near `listServers` at line 121.

**Interfaces:**
- Consumes: `SetServerTags`, `KnownTags`, `ListServersFiltered`, `ParseTagFilters`, `ErrInvalidTag`.
- Produces: `PUT /api/servers/:id/tags`, `GET /api/servers/tags`, and `?tag=` on `GET /api/servers`.

- [ ] **Step 1: Register the routes**

In `RegisterRoutes`, immediately after `apiGroup.GET("/servers", listServers)`:

```go
		apiGroup.GET("/servers/tags", listKnownTags)
```

and after `apiGroup.POST("/servers/:id/apply-updates", applyUpdates)`:

```go
		apiGroup.PUT("/servers/:id/tags", putServerTags)
```

`/servers/tags` must be registered **before** the `/servers/:id` routes are matched — gin resolves static segments ahead of wildcards, so this works, but keep it grouped with the other `/servers` routes for readability.

- [ ] **Step 2: Replace the listServers handler**

Replace `listServers` in `server/internal/api/handlers.go:121-128` with:

```go
func listServers(c *gin.Context) {
	sel, err := services.ParseTagFilters(c.QueryArray("tag"))
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	servers, err := services.ListServersFiltered(auth.InstanceID(c), sel)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, servers)
}
```

- [ ] **Step 3: Add the two new handlers**

Append near the other server handlers in the same file:

```go
func listKnownTags(c *gin.Context) {
	tags, err := services.KnownTags(auth.InstanceID(c))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, tags)
}

func putServerTags(c *gin.Context) {
	var body struct {
		Tags map[string]string `json:"tags"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
		return
	}

	instanceID := auth.InstanceID(c)
	serverID := c.Param("id")

	before, err := services.GetServer(instanceID, serverID)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
		return
	}

	if err := services.SetServerTags(instanceID, serverID, body.Tags); err != nil {
		if errors.Is(err, services.ErrInvalidTag) {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	services.LogEvent(instanceID, "server.tags_updated", auth.Email(c), serverID, "",
		fmt.Sprintf("tags %v -> %v", before.Tags, body.Tags))
	c.JSON(http.StatusOK, gin.H{"tags": body.Tags})
}
```

Add `"errors"` and `"fmt"` to the file's imports if absent. If the actor helper in this file is named something other than `auth.Email(c)`, use whatever the neighbouring handlers use — grep one existing `services.LogEvent(` call in this file and copy its actor argument exactly.

- [ ] **Step 4: Verify it builds**

Run: `cd server && go build ./... && go vet ./internal/api/`
Expected: no output.

- [ ] **Step 5: Manual smoke test**

Run the stack (`cd deploy && docker compose up -d`), sign in, then:

```bash
curl -b cookies.txt -X PUT localhost:8080/api/servers/<id>/tags \
  -H 'content-type: application/json' -d '{"tags":{"env":"prod","role":"web"}}'
curl -b cookies.txt 'localhost:8080/api/servers?tag=env:prod'
curl -b cookies.txt localhost:8080/api/servers/tags
curl -b cookies.txt -X PUT localhost:8080/api/servers/<id>/tags \
  -H 'content-type: application/json' -d '{"tags":{"ENV":"prod"}}'   # expect 400
```

Expected: the first three succeed, the fourth is 400 with an `invalid tag` message, and `/api/audit` shows a `server.tags_updated` event.

- [ ] **Step 6: Commit**

```bash
git add server/internal/api/handlers.go
git commit -m "feat: tag endpoints for servers"
```

---

### Task 6: Workflow tag targeting

**Files:**
- Modify: `server/internal/models/workflow.go:47-56`
- Modify: `server/internal/services/workflows.go:262-289`
- Modify: `server/internal/services/workflow_runner.go:20-69`

**Interfaces:**
- Consumes: `ResolveTargets`, `ErrNoTargets`, `ValidateTags`.
- Produces: `models.Workflow.TargetTags map[string]string`.

- [ ] **Step 1: Add the model field**

In `server/internal/models/workflow.go`, inside `type Workflow struct`, after `TargetServerIDs`:

```go
	TargetTags      map[string]string `bson:"target_tags,omitempty" json:"target_tags,omitempty"`
```

- [ ] **Step 2: Validate it on create and update**

In `server/internal/services/workflows.go`, inside both `CreateWorkflow` and `UpdateWorkflow`, before the existing `validateTargetServers` call:

```go
	if err := ValidateTags(w.TargetTags); err != nil {
		return err // CreateWorkflow returns (nil, err)
	}
```

Make sure `UpdateWorkflow` also persists the field — if it builds an explicit `$set` document, add `"target_tags": w.TargetTags` to it.

- [ ] **Step 3: Resolve targets in TriggerWorkflow**

In `server/internal/services/workflow_runner.go`, replace lines 25-34 (the `len(wf.TargetServerIDs) == 0` guard and the `validateTargetServers` call) with:

```go
	targets, err := ResolveTargets(instanceID, wf.TargetServerIDs, wf.TargetTags)
	if err != nil {
		return "", err
	}
	if len(wf.Steps) == 0 {
		return "", fmt.Errorf("workflow has no steps")
	}
```

Then replace the `ServerRuns` construction at lines 57-69 with:

```go
		ServerRuns:  make([]models.ServerRun, 0, len(targets)),
	}
	for _, srv := range targets {
		sr := models.ServerRun{ServerID: srv.ServerID, Hostname: srv.Hostname, Status: "queued", RunEnv: map[string]string{}}
		for _, rs := range resolved {
			sr.Steps = append(sr.Steps, models.StepRun{Order: rs.Order, Name: rs.Name, Status: "queued", OutputEnv: map[string]string{}})
		}
		run.ServerRuns = append(run.ServerRuns, sr)
	}
```

This also removes the per-server `getServerByID` lookup — `ResolveTargets` already returned whole `models.Server` values, so the hostname is in hand.

- [ ] **Step 4: Map ErrNoTargets to 400 in the API**

In `server/internal/api/workflows.go`, in the handler for `POST /workflows/:id/run`, before the generic 500:

```go
		if errors.Is(err, services.ErrNoTargets) {
			c.JSON(http.StatusBadRequest, gin.H{"error": "this workflow matches no servers"})
			return
		}
```

Add `"errors"` to the imports if absent.

- [ ] **Step 5: Verify it builds and tests pass**

Run: `cd server && go build ./... && go test ./...`
Expected: build silent, tests PASS. If `validateTargetServers` is now unused, delete it rather than leaving dead code.

- [ ] **Step 6: Manual smoke test**

Tag two servers `env:test`, set a workflow's `target_tags` to `{"env":"test"}` with an empty server list, run it, and confirm the run's `server_runs` holds exactly those two. Then clear both the list and the tags and confirm the run endpoint answers 400.

- [ ] **Step 7: Commit**

```bash
git add server/internal/models/workflow.go server/internal/services/workflows.go server/internal/services/workflow_runner.go server/internal/api/workflows.go
git commit -m "feat: target workflow runs by tag selector"
```

---

### Task 7: Frontend API client

**Files:**
- Modify: `web/lib/api.ts` — `Server` interface near line 24, `Workflow` interface, and the `api` object near line 542.

**Interfaces:**
- Consumes: the routes from Tasks 5 and 6.
- Produces: `Server.tags`, `Workflow.target_tags`, `api.setServerTags`, `api.listKnownTags`, `api.listServers(tags?)`.

- [ ] **Step 1: Add the types**

In the `Server` interface add:

```ts
    tags?: Record<string, string>;
```

In the `Workflow` and `WorkflowInput` interfaces add:

```ts
    target_tags?: Record<string, string>;
```

- [ ] **Step 2: Add the client methods**

Replace the existing `listServers` in the `api` object and add two methods beside it:

```ts
    listServers(tags?: Record<string, string>): Promise<Server[]> {
        const params = Object.entries(tags ?? {}).map(([k, v]) => `tag=${encodeURIComponent(`${k}:${v}`)}`);
        return request<Server[]>(`/servers${params.length ? `?${params.join("&")}` : ""}`);
    },

    listKnownTags(): Promise<Record<string, string[]>> {
        return request<Record<string, string[]>>("/servers/tags");
    },

    setServerTags(serverId: string, tags: Record<string, string>): Promise<{ tags: Record<string, string> }> {
        return request<{ tags: Record<string, string> }>(`/servers/${serverId}/tags`, {
            method: "PUT",
            body: JSON.stringify({ tags }),
        });
    },
```

- [ ] **Step 3: Verify it typechecks**

Run: `cd web && npx tsc --noEmit -p tsconfig.json`
Expected: no output. Any call site of `api.listServers()` still compiles because the argument is optional.

- [ ] **Step 4: Commit**

```bash
git add web/lib/api.ts
git commit -m "feat: tag methods on the web api client"
```

---

### Task 8: Tag chips and editor

**Files:**
- Create: `web/components/servers/TagChips.tsx`
- Modify: `web/app/(app)/servers/[id]/page.tsx` — header block.

**Interfaces:**
- Consumes: `api.setServerTags`, `api.listKnownTags`, `Server.tags`.
- Produces: `<TagChips serverId={string} tags={Record<string,string>} editable={boolean} />`.

- [ ] **Step 1: Write the component**

Create `web/components/servers/TagChips.tsx`:

```tsx
"use client";

import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { Button } from "@/components/ui";

/*
 * A tag is key:value, so the chip shows both halves with the key dimmed — the
 * value is the part people scan for, the key is what disambiguates it.
 */

export function TagChips({ serverId, tags, editable = false }: { serverId: string; tags?: Record<string, string>; editable?: boolean }) {
    const queryClient = useQueryClient();
    const [editing, setEditing] = useState(false);
    const [draft, setDraft] = useState<[string, string][]>(Object.entries(tags ?? {}));
    const [error, setError] = useState<string | null>(null);

    const { data: known } = useQuery({
        queryKey: ["server-tags"],
        queryFn: () => api.listKnownTags(),
        enabled: editing,
        staleTime: 60_000,
    });

    const { mutate: save, isPending } = useMutation({
        mutationFn: () => api.setServerTags(serverId, Object.fromEntries(draft.filter(([k, v]) => k && v))),
        onSuccess: () => {
            queryClient.invalidateQueries({ queryKey: ["servers"] });
            queryClient.invalidateQueries({ queryKey: ["server-tags"] });
            setEditing(false);
            setError(null);
        },
        onError: (e: Error) => setError(e.message),
    });

    const entries = Object.entries(tags ?? {});

    if (!editing) {
        return (
            <div className="flex flex-wrap items-center gap-1.5">
                {entries.length === 0 && <span className="text-xs text-text-tertiary">No tags</span>}
                {entries.map(([k, v]) => (
                    <span key={k} className="rounded-sm border border-border bg-surface-2 px-2 py-0.5 font-mono text-[11px]">
                        <span className="text-text-tertiary">{k}:</span>
                        <span className="text-text-primary">{v}</span>
                    </span>
                ))}
                {editable && (
                    <button
                        type="button"
                        onClick={() => {
                            setDraft(entries);
                            setEditing(true);
                        }}
                        className="rounded-sm px-1.5 py-0.5 text-[11px] text-text-secondary hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
                    >
                        {entries.length === 0 ? "Add tags" : "Edit"}
                    </button>
                )}
            </div>
        );
    }

    return (
        <div className="rounded-lg border border-border bg-surface p-3">
            <datalist id="tag-keys">{Object.keys(known ?? {}).map((k) => <option key={k} value={k} />)}</datalist>

            <div className="flex flex-col gap-2">
                {draft.map(([k, v], i) => (
                    <div key={i} className="flex items-center gap-2">
                        <input
                            list="tag-keys"
                            value={k}
                            onChange={(e) => setDraft((d) => d.map((row, j) => (j === i ? [e.target.value, row[1]] : row)))}
                            placeholder="env"
                            className="w-32 rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-accent/50 focus:outline-none"
                        />
                        <span className="font-mono text-text-tertiary">:</span>
                        <input
                            value={v}
                            onChange={(e) => setDraft((d) => d.map((row, j) => (j === i ? [row[0], e.target.value] : row)))}
                            placeholder="prod"
                            className="w-40 rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-accent/50 focus:outline-none"
                        />
                        <button
                            type="button"
                            onClick={() => setDraft((d) => d.filter((_, j) => j !== i))}
                            className="text-xs text-text-tertiary hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
                            aria-label={`Remove ${k || "tag"}`}
                        >
                            Remove
                        </button>
                    </div>
                ))}
            </div>

            {draft.length < 20 && (
                <button
                    type="button"
                    onClick={() => setDraft((d) => [...d, ["", ""]])}
                    className="mt-2 text-xs text-accent hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
                >
                    Add tag
                </button>
            )}

            {error && <p className="mt-2 text-xs text-danger">{error}</p>}

            <div className="mt-3 flex gap-2">
                <Button size="sm" variant="primary" loading={isPending} onClick={() => save()}>
                    Save tags
                </Button>
                <Button size="sm" variant="ghost" onClick={() => { setEditing(false); setError(null); }}>
                    Cancel
                </Button>
            </div>
        </div>
    );
}
```

- [ ] **Step 2: Mount it on the server detail header**

In `web/app/(app)/servers/[id]/page.tsx`, import it and render it directly under the hostname heading:

```tsx
<TagChips serverId={server.server_id} tags={server.tags} editable />
```

- [ ] **Step 3: Verify it builds**

Run: `cd web && npx tsc --noEmit -p tsconfig.json && npx next build`
Expected: `✓ Compiled successfully`.

- [ ] **Step 4: Manual check**

Open a server, add `env:prod`, save, reload — the chip persists. Try `ENV:prod` and confirm the inline error names the rule rather than showing a raw 400.

- [ ] **Step 5: Commit**

```bash
git add web/components/servers/TagChips.tsx "web/app/(app)/servers/[id]/page.tsx"
git commit -m "feat: view and edit server tags"
```

---

### Task 9: Fleet filter bar

**Files:**
- Create: `web/components/servers/TagFilterBar.tsx`
- Modify: `web/app/(app)/servers/page.tsx`

**Interfaces:**
- Consumes: `api.listKnownTags`, `api.listServers(tags)`.
- Produces: `<TagFilterBar value={Record<string,string>} onChange={(v) => void} />`.

- [ ] **Step 1: Write the component**

Create `web/components/servers/TagFilterBar.tsx`:

```tsx
"use client";

import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";

export function TagFilterBar({ value, onChange }: { value: Record<string, string>; onChange: (v: Record<string, string>) => void }) {
    const { data: known } = useQuery({ queryKey: ["server-tags"], queryFn: () => api.listKnownTags(), staleTime: 60_000 });

    const keys = Object.keys(known ?? {}).sort();
    if (keys.length === 0) return null;

    const active = Object.entries(value);

    return (
        <div className="mb-4 flex flex-wrap items-center gap-2">
            {keys.map((k) => (
                <select
                    key={k}
                    value={value[k] ?? ""}
                    onChange={(e) => {
                        const next = { ...value };
                        if (e.target.value) next[k] = e.target.value;
                        else delete next[k];
                        onChange(next);
                    }}
                    className="rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-accent/50 focus:outline-none"
                >
                    <option value="">{k}: any</option>
                    {(known?.[k] ?? []).map((v) => (
                        <option key={v} value={v}>{`${k}: ${v}`}</option>
                    ))}
                </select>
            ))}
            {active.length > 0 && (
                <button
                    type="button"
                    onClick={() => onChange({})}
                    className="text-xs text-text-secondary hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
                >
                    Clear {active.length} {active.length === 1 ? "filter" : "filters"}
                </button>
            )}
        </div>
    );
}
```

- [ ] **Step 2: Drive the list from the URL**

In `web/app/(app)/servers/page.tsx`, read the filter from `useSearchParams`, write it back with `router.replace`, and pass it to the query so a filtered fleet view is a shareable URL:

```tsx
const searchParams = useSearchParams();
const router = useRouter();

const selected = Object.fromEntries(
    searchParams.getAll("tag").map((t) => t.split(":")).filter((p) => p.length === 2),
) as Record<string, string>;

function setSelected(next: Record<string, string>) {
    const qs = Object.entries(next).map(([k, v]) => `tag=${encodeURIComponent(`${k}:${v}`)}`).join("&");
    router.replace(qs ? `/servers?${qs}` : "/servers");
}

const { data: servers, isLoading } = useQuery({
    queryKey: ["servers", selected],
    queryFn: () => api.listServers(selected),
});
```

Render `<TagFilterBar value={selected} onChange={setSelected} />` above the list, and add a tags cell to each row using the read-only form: `<TagChips serverId={s.server_id} tags={s.tags} />`.

A page reading `useSearchParams` must be inside a `<Suspense>` boundary in the App Router. If the build complains, wrap the page body in one.

- [ ] **Step 3: Verify it builds**

Run: `cd web && npx tsc --noEmit -p tsconfig.json && npx next build`
Expected: `✓ Compiled successfully`.

- [ ] **Step 4: Manual check**

Filter to `env: prod`, copy the URL, open it in a new tab, and confirm the filter is still applied.

- [ ] **Step 5: Commit**

```bash
git add web/components/servers/TagFilterBar.tsx "web/app/(app)/servers/page.tsx"
git commit -m "feat: filter the fleet list by tag"
```

---

### Task 10: Workflow target section

**Files:**
- Modify: `web/app/(app)/workflows/[id]/page.tsx`

**Interfaces:**
- Consumes: `Workflow.target_tags`, `api.listServers()`, `api.listKnownTags()`.
- Produces: no new exports.

- [ ] **Step 1: Add the tag selector beside the server picker**

In the workflow's target editor, add a key/value row editor writing to `target_tags`, using the same two-input pattern as `TagChips` (repeat it locally rather than extracting — the editing model differs: this one has no save button of its own, it feeds the workflow's own save).

- [ ] **Step 2: Add the resolved readout**

Compute the union client-side from the server list already fetched — no new endpoint, because the browser holds the whole fleet already:

```tsx
const matched = (servers ?? []).filter(
    (s) =>
        targetServerIds.includes(s.server_id) ||
        (Object.keys(targetTags).length > 0 && Object.entries(targetTags).every(([k, v]) => s.tags?.[k] === v)),
);
```

Render it as the readout the design promised:

```tsx
<p className="font-mono text-xs text-text-secondary" title={matched.map((s) => s.hostname).join("\n")}>
    Runs on {matched.length} {matched.length === 1 ? "server" : "servers"}
</p>
{matched.length === 0 && (
    <p className="text-xs text-danger">This workflow matches no servers and cannot run.</p>
)}
```

The filter above must stay identical in meaning to `UnionTargets` and `MatchesTags` in Go — empty selector matches nothing, AND across keys. If one changes, change both.

- [ ] **Step 3: Verify it builds**

Run: `cd web && npx tsc --noEmit -p tsconfig.json && npx next build`
Expected: `✓ Compiled successfully`.

- [ ] **Step 4: Manual check**

Set a tag selector with no explicit servers, confirm the readout counts correctly and that saving then running dispatches to exactly those machines.

- [ ] **Step 5: Commit**

```bash
git add "web/app/(app)/workflows/[id]/page.tsx"
git commit -m "feat: target workflows by tag in the designer"
```

---

### Task 11: Documentation

**Files:**
- Modify: `docsite/docs/vantage/servers.md`, `docsite/docs/vantage/workflows.md`, `CLAUDE.md`

- [ ] **Step 1: Document tags for users**

Add a "Tags" section to `docsite/docs/vantage/servers.md` covering: what a tag is (`key:value`), the character and count rules stated plainly, how to filter the fleet, and that the filter is in the URL. Add a "Targeting" section to `docsite/docs/vantage/workflows.md` covering the union rule, that an empty selector matches nothing, and that offline servers are still targeted and will fail visibly rather than being skipped.

- [ ] **Step 2: Update the contributor map**

In `CLAUDE.md`, under the SSH keys / Workflows subsystem notes, add a short paragraph: tags are a map on `servers` with no registry collection, `ResolveTargets` is the single answer to which servers a workflow touches, the union deduplicates, an empty selector matches nothing on purpose, and the wildcard index exists because the queried key is user-chosen.

- [ ] **Step 3: Commit**

```bash
git add docsite/docs/vantage/servers.md docsite/docs/vantage/workflows.md CLAUDE.md
git commit -m "docs: server tags and workflow tag targeting"
```

---

## Verification

Full check before calling this done:

```bash
cd server && go build ./... && go vet ./... && go test ./...
cd ../web && npx tsc --noEmit -p tsconfig.json && npx next build
cd .. && graphify update .
```

All must pass. `go test ./...` should report the tag and target suites passing, and no package should report a build failure.
