Ctrl K
ComunidadGuías…

internal/artifact/tags.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package artifact

import (
	"fmt"
	"strings"

	"github.com/stump-wtf/cairn/internal/errs"
)

// Tags are short, flat strings a creator attaches to an artifact so a
// downstream consumer can route it without reading the body — a Switchboard
// rule telling a handoff work order ("handoff") from an ordinary paste, and
// picking the lane that should run it ("lane:auto").
//
// Tags are client-asserted and are NOT provenance. ActorID and Channel are
// derived server-side from the authenticated surface; a tag is whatever the
// caller sent. A consumer MUST NOT base a trust or authorization decision on a
// tag: "handoff" says what the creator wants, never who the creator is. Trust
// comes from ActorID, the authenticated principal.
//
// Governing: ADR-0018 (Client-Asserted Artifact Tags), SPEC-0002 REQ "Artifact
// Tags"

// Tag bounds. They keep tags routing metadata rather than a second body: the
// whole list rides in every outbound event and is rendered in the web panel.
const (
	MaxTags     = 32
	MaxTagBytes = 64
)

// HandoffTag is the tag the handoff convention uses to mark an artifact as a
// work order for another agent. It is client-asserted like every other tag and
// says what the creator wants, never who they are; it is a constant so the one
// place it is measured — the tagged_handoff boolean of
// cairn_artifacts_created_total (SPEC-0014 REQ-4) — cannot drift from the
// convention. The tag SET is never a label: it is unbounded (ADR-0018).
const HandoffTag = "handoff"

// tagPunct is the punctuation a tag may use beside [a-z0-9]. ":" and "/" carry
// the handoff convention's "key:value" and "owner/name" shapes; "#" is admitted
// because its issue tag ("issue:stump.wtf/cairn#42") cannot be written without
// it. The comma is deliberately absent, so it is always safe as a list
// separator on the wire.
const tagPunct = "._:/#-"

// NormalizeTags validates raw tags and returns them deduplicated in
// first-occurrence order. A malformed tag rejects the whole list — nothing is
// truncated, lowercased or otherwise rewritten — and so does a list of more
// than MaxTags distinct tags; exact repeats are simply dropped. Nil or empty
// input yields nil.
//
// Every failing tag is reported, each as a violation on tags[i] (the MCP
// argument's shape), so a caller fixes them all in one round trip.
//
// Governing: ADR-0025, SPEC-0019 VE-1, VE-4
func NormalizeTags(raw []string) ([]string, error) {
	if len(raw) == 0 {
		return nil, nil
	}
	out := make([]string, 0, len(raw))
	seen := make(map[string]struct{}, len(raw))
	var bad []*errs.Invalid
	tooMany := false
	for i, t := range raw {
		if inv := CheckTag(t, fmt.Sprintf("tags[%d]", i), errs.LocBody); inv != nil {
			bad = append(bad, inv)
			continue
		}
		if _, dup := seen[t]; dup {
			continue
		}
		if len(out) == MaxTags {
			tooMany = true
			continue
		}
		seen[t] = struct{}{}
		out = append(out, t)
	}
	if tooMany {
		bad = append(bad, TooManyTags("tags", errs.LocBody))
	}
	if inv := errs.Join(bad...); inv != nil {
		return nil, inv
	}
	return out, nil
}

// ValidateTag checks one tag: 1 to MaxTagBytes bytes of lowercase [a-z0-9]
// and tagPunct. Uppercase is rejected rather than folded, so what a consumer
// matches is byte-for-byte what the creator sent.
func ValidateTag(t string) error {
	if inv := CheckTag(t, "tag", errs.LocBody); inv != nil {
		return inv
	}
	return nil
}

// CheckTag is ValidateTag reporting a violation on the caller's own field and
// location, or nil when the tag is valid. A tag that is wrong only in its case
// is `uppercase`, so the fix it names is exact; any other disallowed character
// is `invalid_charset`.
//
// Governing: ADR-0018, ADR-0025, SPEC-0019 VE-1, VE-3
func CheckTag(t, field string, loc errs.Location) *errs.Invalid {
	switch {
	case t == "":
		return errs.Violate(field, loc, errs.ReasonRequired)
	case len(t) > MaxTagBytes:
		return errs.Violate(field, loc, errs.ReasonTooLong,
			errs.WithValue(t), errs.WithLimit(MaxTagBytes, errs.UnitBytes))
	}
	upper := false
	for i := 0; i < len(t); i++ {
		c := t[i]
		switch {
		case (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || strings.IndexByte(tagPunct, c) >= 0:
		case c >= 'A' && c <= 'Z':
			upper = true
		default:
			return errs.Violate(field, loc, errs.ReasonInvalidCharset,
				errs.WithValue(t), errs.WithExpect("lowercase a-z, 0-9 and "+tagPunct))
		}
	}
	if upper {
		return errs.Violate(field, loc, errs.ReasonUppercase, errs.WithValue(t))
	}
	return nil
}

// TooManyTags is the violation for a list of more than MaxTags distinct tags.
func TooManyTags(field string, loc errs.Location) *errs.Invalid {
	return errs.Violate(field, loc, errs.ReasonTooMany, errs.WithLimit(MaxTags, errs.UnitCount))
}

// validateNormalizedTags is the aggregate invariant: tags already normalized,
// so a persisted artifact can never hold a malformed or repeated tag.
func validateNormalizedTags(tags []string) error {
	norm, err := NormalizeTags(tags)
	if err != nil {
		return err
	}
	if len(norm) != len(tags) {
		return errs.Validationf("artifact: tags must not repeat")
	}
	return nil
}