internal/artifact/tags.go
Rama main · réplica de lectura del repositorio original.
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
}