Files
krow_backend/go-api/internal/service/definitions.go
Suriyakumarvijayanayagam 80ba57ace3 Refuse an edit that would rewrite an already-published version
§3 says a published version is immutable and editing publishes a new one.
The machinery for that was all present — an append-only definition_versions
table, a trigger, and repo.VersionsRepo.Snapshot, which already refuses to
store a version number whose content differs from what is stored.

Nothing acted on that refusal. snapshotIfPublished's error was discarded at
both call sites (`_ = s.snapshotIfPublished(...)`), and deliberately so: the
comment there explains that losing an author's work to protect a record of it
is the wrong trade. That is right for a recording failure and wrong for
exactly one case. A conflict is not the history failing to record; it is the
invariant firing.

The effect was silent. Editing a published agent without raising the
frontmatter version answered 200: the live row took the new text, the history
kept the old, and two different definitions were both called v1. Because
runtime.LoadAgentVersion resolves a pin by returning the CURRENT definition
whenever the pinned number equals the current one, a conversation pinned to v1
then ran the rewritten instructions while the audit trail showed the
originals. Verified against a live stack before the fix: PATCH answered 200,
agent_definitions held "SILENTLY CHANGED" and definition_versions still held
the published text, both labelled v2.

So the conflict is now detected before anything is written, where refusing
costs the author nothing but a version bump. The post-write snapshot keeps its
original contract for every other kind of failure, and republishing a version
unchanged stays the no-op it was. Drafts are untouched: they carry no promise,
and are still rewritten in place.

Not addressed here, and each its own change:

  - cmd/importagents never creates versions at all (documented at main.go:10),
    so the nine file-published organization agents are outside this entirely
    and every deploy still mutates v1 in place.
  - skill definitions never snapshot, so KindSkill exists with nothing writing
    it. Fixing that changes skill authoring behaviour and wants its own pass.
  - a concurrent publish of one version number with differing content can still
    pass this check and be caught by the unique index afterwards, where it is
    swallowed as before. That is the pre-existing behaviour, narrowed rather
    than removed.

Tests: the new case fails without the fix — the live row takes the rewritten
text at version 1 — and passes with it. Full suite green against PostgreSQL,
with only TestLive* skipped, which is what CI allows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
2026-08-28 18:28:20 +05:30

655 lines
21 KiB
Go

package service
import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/definition"
"github.com/krow/krow-backend/go-api/internal/domain"
"github.com/krow/krow-backend/go-api/internal/repo"
)
// allowedDefinitionFilters names the accepted query parameters for definition collections.
var allowedDefinitionFilters = map[string]bool{
"visibility": true,
"status": true,
"definition_id": true,
"sort": true,
"limit": true,
"offset": true,
}
// DefinitionsService manages authored Agent and Skill definitions.
type DefinitionsService struct {
repo *repo.DefinitionsRepo
// versions is the append-only history beside the editable definition.
// Written on publish, never on save — see snapshotIfPublished.
versions *repo.VersionsRepo
// unknownTools reports which of a spec's tool names are not registered.
// nil means no check — the shipped importer path, which has its own.
unknownTools func([]string) []string
}
// NewDefinitions builds a definitions service over a repository.
func NewDefinitions(db repo.Querier) *DefinitionsService {
return &DefinitionsService{
repo: repo.NewDefinitionsRepo(db),
versions: repo.NewVersionsRepo(db),
}
}
// WithToolCheck teaches the service which tool names exist.
//
// §3 requires an unknown tool name to fail validation at PUBLISH. Without it
// the runtime records the name and drops it, so a typo produces an agent that
// is silently missing a capability its author believes it has — and the author
// finds out by watching it fail to answer.
//
// Injected rather than imported so this package does not depend on the tool
// registry, and so a test can supply its own vocabulary.
func (s *DefinitionsService) WithToolCheck(unknown func([]string) []string) *DefinitionsService {
s.unknownTools = unknown
return s
}
// rejectUnknownTools fails a definition that names a tool that does not exist.
func (s *DefinitionsService) rejectUnknownTools(markdown string) error {
if s.unknownTools == nil {
return nil
}
agent, err := definition.ParseAgent(markdown, definition.Options{})
if err != nil || agent == nil {
// ValidateAgent has already run and reported anything real; a parse
// failure here is not a second opinion worth raising.
return nil
}
if bad := s.unknownTools(agent.Tools); len(bad) > 0 {
return domain.Validation(
fmt.Sprintf("unknown tool(s): %s", strings.Join(bad, ", ")),
map[string]string{"tools": strings.Join(bad, ", ")})
}
return nil
}
// ParseListParams validates query parameters for listing definitions.
func (s *DefinitionsService) ParseListParams(q url.Values) (repo.DefinitionListParams, error) {
p := repo.DefinitionListParams{
Limit: 100,
Sort: "created_date",
Desc: true,
}
for name := range q {
if !allowedDefinitionFilters[name] {
return p, domain.Invalid(fmt.Sprintf("unknown filter field %q", name))
}
}
if raw := q.Get("visibility"); raw != "" {
if raw != "personal" && raw != "organization" {
return p, domain.Invalid("visibility must be one of: personal, organization")
}
p.Visibility = raw
}
if raw := q.Get("status"); raw != "" {
p.Status = raw
}
if raw := q.Get("definition_id"); raw != "" {
p.DefinitionID = raw
}
if raw := q.Get("sort"); raw != "" {
field := raw
desc := false
if strings.HasPrefix(field, "-") {
desc = true
field = field[1:]
}
switch field {
case "created_date", "updated_date", "name", "definition_id", "status", "version":
p.Sort = field
p.Desc = desc
default:
return p, domain.Invalid(fmt.Sprintf("cannot sort by %q", field))
}
}
if raw := q.Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return p, domain.Invalid("limit must be a non-negative integer")
}
if n > MaxLimit {
n = MaxLimit
}
p.Limit = n
}
if raw := q.Get("offset"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return p, domain.Invalid("offset must be a non-negative integer")
}
p.Offset = n
}
return p, nil
}
/* ── Agents ─────────────────────────────────────────────────────────────── */
// ListAgents returns a page of authored agent definitions.
func (s *DefinitionsService) ListAgents(ctx context.Context, ident authctx.Identity, p repo.DefinitionListParams) (*domain.Page, error) {
records, total, err := s.repo.ListAgents(ctx, ident, p)
if err != nil {
return nil, err
}
if records == nil {
records = []domain.Record{}
}
return &domain.Page{
Records: records,
Total: total,
Limit: p.Limit,
Offset: p.Offset,
}, nil
}
// GetAgent returns one agent definition by id within the caller's tenant and ownership scope.
func (s *DefinitionsService) GetAgent(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) {
if !isUUID(id) {
return nil, domain.NotFound("AgentDefinition", id)
}
rec, err := s.repo.GetAgent(ctx, ident, id)
if err != nil {
return nil, err
}
if rec == nil {
return nil, domain.NotFound("AgentDefinition", id)
}
return rec, nil
}
// CreateAgent validates, parses and persists a new authored agent definition.
func (s *DefinitionsService) CreateAgent(ctx context.Context, ident authctx.Identity, body domain.Record) (domain.Record, error) {
mdRaw, ok := body["markdown"]
if !ok || mdRaw == nil {
return nil, domain.Validation("Paste or upload a Markdown definition.", nil)
}
markdown, isStr := mdRaw.(string)
if !isStr {
return nil, domain.Validation("markdown must be a string", nil)
}
if err := definition.ValidateAgent(markdown); err != nil {
return nil, domain.Validation(err.Error(), nil)
}
if err := s.rejectUnknownTools(markdown); err != nil {
return nil, err
}
visibility := "personal"
if visRaw, ok := body["visibility"]; ok && visRaw != nil {
v, isStr := visRaw.(string)
if !isStr || (v != "personal" && v != "organization") {
return nil, domain.Validation("visibility must be one of: personal, organization", map[string]string{"visibility": "invalid"})
}
visibility = v
}
if visibility == "organization" {
role, known := domain.ParseRole(ident.Role)
if !known || role == domain.RoleTalent {
return nil, domain.Forbidden()
}
}
agent, err := definition.ParseAgent(markdown, definition.Options{})
if err != nil {
return nil, domain.Validation("That definition could not be parsed. "+err.Error(), nil)
}
input := repo.AgentInsertInput{
DefinitionID: agent.ID,
OrgID: ident.OrgID,
Visibility: visibility,
CreatedBy: &ident.UserID,
Markdown: markdown,
Status: agent.Status,
Version: agent.Version,
Name: agent.Name,
Description: agent.Description,
Pages: agent.Pages,
}
if visibility == "personal" {
input.OwnerUserID = &ident.UserID
}
if err := s.refusePublishedRewrite(ctx, ident, repo.KindAgent,
agent.ID, markdown, agent.Status, agent.Version); err != nil {
return nil, err
}
rec, err := s.repo.InsertAgent(ctx, ident, input)
if err != nil {
return nil, err
}
// Recorded after the row exists, and never allowed to fail the save: the
// author's work is already stored, and losing it to protect a record of it
// would be the wrong trade.
_ = s.snapshotIfPublished(ctx, ident, repo.KindAgent, rec)
return rec, nil
}
// UpdateAgent validates and applies updates to an authored agent definition.
func (s *DefinitionsService) UpdateAgent(ctx context.Context, ident authctx.Identity, id string, patch domain.Record) (domain.Record, error) {
if !isUUID(id) {
return nil, domain.NotFound("AgentDefinition", id)
}
existing, err := s.repo.GetAgent(ctx, ident, id)
if err != nil {
return nil, err
}
if existing == nil {
return nil, domain.NotFound("AgentDefinition", id)
}
if existing["visibility"] == "organization" {
role, known := domain.ParseRole(ident.Role)
if !known || role == domain.RoleTalent {
return nil, domain.Forbidden()
}
}
if visRaw, ok := patch["visibility"]; ok && visRaw != nil {
if v, isStr := visRaw.(string); isStr && v != existing["visibility"] {
return nil, domain.Validation("visibility cannot be modified after creation", map[string]string{"visibility": "immutable"})
}
}
var input repo.AgentUpdateInput
if mdRaw, ok := patch["markdown"]; ok && mdRaw != nil {
markdown, isStr := mdRaw.(string)
if !isStr {
return nil, domain.Validation("markdown must be a string", nil)
}
if err := definition.ValidateAgent(markdown); err != nil {
return nil, domain.Validation(err.Error(), nil)
}
if err := s.rejectUnknownTools(markdown); err != nil {
return nil, err
}
agent, err := definition.ParseAgent(markdown, definition.Options{})
if err != nil {
return nil, domain.Validation("That definition could not be parsed. "+err.Error(), nil)
}
input.Markdown = &markdown
input.DefinitionID = &agent.ID
input.Name = &agent.Name
input.Description = &agent.Description
input.Status = &agent.Status
input.Version = &agent.Version
input.Pages = agent.Pages
if err := s.refusePublishedRewrite(ctx, ident, repo.KindAgent,
agent.ID, markdown, agent.Status, agent.Version); err != nil {
return nil, err
}
} else if statusRaw, ok := patch["status"]; ok && statusRaw != nil {
status, isStr := statusRaw.(string)
if !isStr || (status != "draft" && status != "published" && status != "archived") {
return nil, domain.Validation("status must be one of: draft, published, archived", map[string]string{"status": "invalid"})
}
input.Status = &status
}
rec, err := s.repo.UpdateAgent(ctx, ident, id, input)
if err != nil {
return nil, err
}
_ = s.snapshotIfPublished(ctx, ident, repo.KindAgent, rec)
return rec, nil
}
// DeleteAgent removes an agent definition following idempotent delete semantics.
func (s *DefinitionsService) DeleteAgent(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) {
if !isUUID(id) {
return domain.Record{"id": id}, nil
}
existing, err := s.repo.GetAgent(ctx, ident, id)
if err != nil {
return nil, err
}
if existing == nil {
return domain.Record{"id": id}, nil
}
if existing["visibility"] == "organization" {
role, known := domain.ParseRole(ident.Role)
if !known || role == domain.RoleTalent {
return nil, domain.Forbidden()
}
}
if _, err := s.repo.DeleteAgent(ctx, ident, id); err != nil {
return nil, err
}
return domain.Record{"id": id}, nil
}
/* ── Skills ─────────────────────────────────────────────────────────────── */
// ListSkills returns a page of authored skill definitions.
func (s *DefinitionsService) ListSkills(ctx context.Context, ident authctx.Identity, p repo.DefinitionListParams) (*domain.Page, error) {
records, total, err := s.repo.ListSkills(ctx, ident, p)
if err != nil {
return nil, err
}
if records == nil {
records = []domain.Record{}
}
return &domain.Page{
Records: records,
Total: total,
Limit: p.Limit,
Offset: p.Offset,
}, nil
}
// GetSkill returns one skill definition by id within the caller's tenant and ownership scope.
func (s *DefinitionsService) GetSkill(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) {
if !isUUID(id) {
return nil, domain.NotFound("SkillDefinition", id)
}
rec, err := s.repo.GetSkill(ctx, ident, id)
if err != nil {
return nil, err
}
if rec == nil {
return nil, domain.NotFound("SkillDefinition", id)
}
return rec, nil
}
// CreateSkill validates, parses and persists a new authored skill definition.
func (s *DefinitionsService) CreateSkill(ctx context.Context, ident authctx.Identity, body domain.Record) (domain.Record, error) {
mdRaw, ok := body["markdown"]
if !ok || mdRaw == nil {
return nil, domain.Validation("Paste or upload a Markdown definition.", nil)
}
markdown, isStr := mdRaw.(string)
if !isStr {
return nil, domain.Validation("markdown must be a string", nil)
}
if err := definition.ValidateSkill(markdown); err != nil {
return nil, domain.Validation(err.Error(), nil)
}
visibility := "personal"
if visRaw, ok := body["visibility"]; ok && visRaw != nil {
v, isStr := visRaw.(string)
if !isStr || (v != "personal" && v != "organization") {
return nil, domain.Validation("visibility must be one of: personal, organization", map[string]string{"visibility": "invalid"})
}
visibility = v
}
if visibility == "organization" {
role, known := domain.ParseRole(ident.Role)
if !known || role == domain.RoleTalent {
return nil, domain.Forbidden()
}
}
skill, err := definition.ParseSkill(markdown, definition.Options{})
if err != nil {
return nil, domain.Validation("That definition could not be parsed. "+err.Error(), nil)
}
input := repo.SkillInsertInput{
DefinitionID: skill.ID,
OrgID: ident.OrgID,
Visibility: visibility,
CreatedBy: &ident.UserID,
Markdown: markdown,
Status: skill.Status,
Name: skill.Name,
Description: skill.Description,
Pages: skill.Pages,
}
if visibility == "personal" {
input.OwnerUserID = &ident.UserID
}
return s.repo.InsertSkill(ctx, ident, input)
}
// UpdateSkill validates and applies updates to an authored skill definition.
func (s *DefinitionsService) UpdateSkill(ctx context.Context, ident authctx.Identity, id string, patch domain.Record) (domain.Record, error) {
if !isUUID(id) {
return nil, domain.NotFound("SkillDefinition", id)
}
existing, err := s.repo.GetSkill(ctx, ident, id)
if err != nil {
return nil, err
}
if existing == nil {
return nil, domain.NotFound("SkillDefinition", id)
}
if existing["visibility"] == "organization" {
role, known := domain.ParseRole(ident.Role)
if !known || role == domain.RoleTalent {
return nil, domain.Forbidden()
}
}
if visRaw, ok := patch["visibility"]; ok && visRaw != nil {
if v, isStr := visRaw.(string); isStr && v != existing["visibility"] {
return nil, domain.Validation("visibility cannot be modified after creation", map[string]string{"visibility": "immutable"})
}
}
var input repo.SkillUpdateInput
if mdRaw, ok := patch["markdown"]; ok && mdRaw != nil {
markdown, isStr := mdRaw.(string)
if !isStr {
return nil, domain.Validation("markdown must be a string", nil)
}
if err := definition.ValidateSkill(markdown); err != nil {
return nil, domain.Validation(err.Error(), nil)
}
skill, err := definition.ParseSkill(markdown, definition.Options{})
if err != nil {
return nil, domain.Validation("That definition could not be parsed. "+err.Error(), nil)
}
input.Markdown = &markdown
input.DefinitionID = &skill.ID
input.Name = &skill.Name
input.Description = &skill.Description
input.Status = &skill.Status
input.Pages = skill.Pages
} else if statusRaw, ok := patch["status"]; ok && statusRaw != nil {
status, isStr := statusRaw.(string)
if !isStr || (status != "active" && status != "inactive") {
return nil, domain.Validation("status must be one of: active, inactive", map[string]string{"status": "invalid"})
}
input.Status = &status
}
return s.repo.UpdateSkill(ctx, ident, id, input)
}
// DeleteSkill removes a skill definition following idempotent delete semantics.
func (s *DefinitionsService) DeleteSkill(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) {
if !isUUID(id) {
return domain.Record{"id": id}, nil
}
existing, err := s.repo.GetSkill(ctx, ident, id)
if err != nil {
return nil, err
}
if existing == nil {
return domain.Record{"id": id}, nil
}
if existing["visibility"] == "organization" {
role, known := domain.ParseRole(ident.Role)
if !known || role == domain.RoleTalent {
return nil, domain.Forbidden()
}
}
if _, err := s.repo.DeleteSkill(ctx, ident, id); err != nil {
return nil, err
}
return domain.Record{"id": id}, nil
}
/* ── Publishing ─────────────────────────────────────────────────────────── */
// refusePublishedRewrite fails a publish that would change a version already
// published, BEFORE anything is written.
//
// snapshotIfPublished below deliberately never fails a save: the author's work
// is already stored and losing it to protect a record of it is the wrong trade.
// That is right for a recording failure — the disk, the pool, the network — and
// wrong for exactly one case. When repo.VersionsRepo.Snapshot refuses because
// the version already says something different, that is not the history failing
// to record; it is §3 firing. Swallowing it leaves two different definitions
// both called v2: the live row the runtime serves, and the snapshot the history
// shows. runtime.LoadAgentVersion resolves a pin by returning the CURRENT
// definition whenever the pinned number equals the current one, so the run gets
// the changed text while the audit trail says otherwise.
//
// So the conflict is detected here instead, before the write, where refusing
// costs the author nothing but a version bump. The post-write snapshot keeps
// its original contract for every other kind of failure.
//
// A concurrent publish of the same number with different content can still slip
// past this check and be caught by the unique index afterwards, where it is
// swallowed as before. That leaves the live row ahead of its snapshot, which is
// the pre-existing behaviour and not something this guard makes worse.
func (s *DefinitionsService) refusePublishedRewrite(ctx context.Context, ident authctx.Identity,
kind repo.VersionKind, definitionID, markdown, status string, version int) error {
if s.versions == nil || status != "published" || definitionID == "" || version < 1 {
return nil
}
stored, err := s.versions.Load(ctx, ident, kind, definitionID, version)
if err != nil || stored == nil {
// Absent (the ordinary case for a new version) or unreadable. Either
// way there is no published text to contradict, so this is not the
// place to fail the save.
return nil
}
if stored.Markdown == markdown {
return nil // republishing the same version unchanged is a no-op
}
return domain.Conflict(fmt.Sprintf(
"version %d of %q is already published and says something different; "+
"raise the version in the frontmatter to publish a change",
version, definitionID))
}
// snapshotIfPublished records an immutable copy when a definition is published.
//
// §3: editing publishes a NEW version, and a published version never changes.
// This is the half that records it. The half that enforces it is a trigger on
// the table, because the repository is not the only thing that can reach it.
//
// ONLY ON PUBLISH. A draft is a work in progress and snapshotting every save
// would fill the history with keystrokes — the version number would stop
// meaning "a thing somebody decided to ship" and start meaning "a time somebody
// pressed save", which is the number a run records and a person has to
// recognise.
//
// A failure here does NOT fail the save. The definition is already written;
// refusing the whole operation because its history could not be recorded would
// lose the author's work to protect a record of it. It is returned so the
// caller can log it, and the missing version shows up as a gap rather than as
// a wrong answer.
func (s *DefinitionsService) snapshotIfPublished(ctx context.Context, ident authctx.Identity,
kind repo.VersionKind, rec domain.Record) error {
if s.versions == nil || rec == nil {
return nil
}
status, _ := rec["status"].(string)
if status != "published" {
return nil
}
markdown, _ := rec["markdown"].(string)
definitionID, _ := rec["definition_id"].(string)
if markdown == "" || definitionID == "" {
return nil
}
version := 1
switch v := rec["version"].(type) {
case int:
version = v
case int32:
version = int(v)
case int64:
version = int(v)
case float64:
version = int(v)
}
name, _ := rec["name"].(string)
description, _ := rec["description"].(string)
var pages []string
if raw, ok := rec["pages"].([]string); ok {
pages = raw
} else if raw, ok := rec["pages"].([]any); ok {
for _, p := range raw {
if str, ok := p.(string); ok {
pages = append(pages, str)
}
}
}
return s.versions.Snapshot(ctx, ident, repo.SnapshotInput{
Kind: kind,
DefinitionID: definitionID,
Version: version,
Markdown: markdown,
Name: name,
Description: description,
Pages: pages,
})
}
// AgentHistory lists an agent's published versions, newest first.
func (s *DefinitionsService) AgentHistory(ctx context.Context, ident authctx.Identity,
definitionID string, limit int) ([]repo.Version, error) {
return s.versions.History(ctx, ident, repo.KindAgent, definitionID, limit)
}
// AgentVersion loads one published version of an agent, as it was.
func (s *DefinitionsService) AgentVersion(ctx context.Context, ident authctx.Identity,
definitionID string, version int) (*repo.Version, error) {
return s.versions.Load(ctx, ident, repo.KindAgent, definitionID, version)
}