Refuse archiving an agent that a published agent still delegates to

§3 says an unknown subagent key fails at publish, not at run time, and
refuseSubagentCycle enforced that in one direction only: the edge was
checked when the PARENT was written, and nothing re-checked it when the
CHILD was later archived. So a spec could validate on Monday and be
delegating into nothing by Friday.

That is what happened on 2026-09-15. activity-agent was archived while
krow-workforce-agent v2 still listed it, and every run since logged
runtime.unknown_subagent and answered activity questions without its
activity capability -- quietly, because the parent still Completed.

Both archive paths now refuse with 409 naming the dependents: the
status-only patch the UI sends, and a markdown save whose frontmatter
says archived. Only PUBLISHED parents count, so an abandoned draft
cannot pin a production agent in place. Unlike the cycle check this
fails closed when the graph cannot be read, because the only backstop
here is the failure it exists to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
This commit is contained in:
2026-09-22 12:48:09 +05:30
parent 822b3b1edb
commit b765495eb7
2 changed files with 157 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/url"
"sort"
"strconv"
"strings"
@@ -352,6 +353,11 @@ func (s *DefinitionsService) UpdateAgent(ctx context.Context, ident authctx.Iden
if err := s.refuseSubagentCycle(ctx, ident, agent.ID, agent.Subagents); err != nil {
return nil, err
}
if agent.Status == "archived" {
if err := s.refuseArchivingDependency(ctx, ident, agent.ID); err != nil {
return nil, err
}
}
if err := s.refusePublishedRewrite(ctx, ident, repo.KindAgent,
agent.ID, markdown, agent.Status, agent.Version); err != nil {
return nil, err
@@ -361,6 +367,12 @@ func (s *DefinitionsService) UpdateAgent(ctx context.Context, ident authctx.Iden
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"})
}
if status == "archived" {
did, _ := existing["definition_id"].(string)
if err := s.refuseArchivingDependency(ctx, ident, did); err != nil {
return nil, err
}
}
input.Status = &status
}
@@ -671,6 +683,70 @@ func (s *DefinitionsService) refuseSubagentCycle(ctx context.Context,
return nil
}
// refuseArchivingDependency fails an archive while a published agent in the
// organization still delegates to the definition.
//
// §3 says an unknown subagent key fails at publish, not at run time, and
// refuseSubagentCycle is half of that. This is the other half. Publish
// validation proves the edge exists when the PARENT is written; nothing
// re-checked it when the CHILD was later archived, so a spec could pass
// validation on Monday and be delegating into nothing by Friday. That is what
// happened to krow-workforce-agent on 2026-09-15: activity-agent was archived
// under it, and every run since logged runtime.unknown_subagent and answered
// activity questions without its activity capability — quietly, because the
// parent still Completed.
//
// Only published parents count. A draft that names this agent is the author's
// problem at their next publish, where rejectUnknownTools-style validation
// will tell them; refusing an archive on the strength of a draft would let an
// abandoned experiment pin a production agent in place forever.
//
// Unlike refuseSubagentCycle this FAILS CLOSED when the graph cannot be read.
// The cycle check can afford to fail open because the runtime depth cap holds
// regardless; the only backstop here is a parent that keeps answering with a
// capability missing, which is the failure this exists to prevent.
func (s *DefinitionsService) refuseArchivingDependency(ctx context.Context,
ident authctx.Identity, definitionID string) error {
if definitionID == "" {
return nil
}
rows, _, err := s.repo.ListAgents(ctx, ident, repo.DefinitionListParams{
Visibility: "organization", Limit: 500,
})
if err != nil {
return domain.Internal(fmt.Errorf("could not check whether any published agent delegates to %q: %w", definitionID, err))
}
var dependents []string
for _, rec := range rows {
id, _ := rec["definition_id"].(string)
markdown, _ := rec["markdown"].(string)
status, _ := rec["status"].(string)
if id == "" || id == definitionID || markdown == "" || status != "published" {
continue
}
parsed, err := definition.ParseAgent(markdown, definition.Options{})
if err != nil || parsed == nil {
continue
}
for _, sub := range parsed.Subagents {
if sub == definitionID {
dependents = append(dependents, id)
break
}
}
}
if len(dependents) == 0 {
return nil
}
sort.Strings(dependents)
return domain.Conflict(fmt.Sprintf(
"%s cannot be archived while a published agent delegates to it: %s. "+
"Publish a version of each without it in `subagents` first, or archive them too.",
definitionID, strings.Join(dependents, ", ")))
}
// refusePublishedRewrite fails a publish that would change a version already
// published, BEFORE anything is written.
//