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

@@ -1520,3 +1520,84 @@ Find the shifts nobody has taken.
t.Errorf("version moved on a restore: %v -> %v", arc["version"], got)
}
}
// The reverse of the cycle and unknown-key checks. Those prove an edge is
// valid when the PARENT is written; this proves the edge stays valid when the
// CHILD is archived. Without it a published parent keeps delegating into
// nothing — exactly what krow-workforce-agent did after activity-agent was
// archived under it on 2026-09-15.
func TestArchivingADelegatedSubagentIsRefused(t *testing.T) {
r := newRBAC(t)
agent := func(id, name, status string, version int, subagents ...string) string {
var sub string
if len(subagents) > 0 {
sub = "subagents:\n"
for _, s := range subagents {
sub += " - " + s + "\n"
}
}
return fmt.Sprintf(`---
id: %s
name: %s
description: part of a delegation graph
status: %s
version: %d
pages:
- candidates
%s---
## Instructions
Delegate.
`, id, name, status, version, sub)
}
res := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": agent("dep-child", "Child", "published", 1), "visibility": "organization",
})
if res.code != http.StatusCreated {
t.Fatalf("create child: status %d (%v)", res.code, res.body)
}
childID, _ := res.record(t)["id"].(string)
res = r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
"markdown": agent("dep-parent", "Parent", "published", 1, "dep-child"), "visibility": "organization",
})
if res.code != http.StatusCreated {
t.Fatalf("create parent: status %d (%v)", res.code, res.body)
}
parentID, _ := res.record(t)["id"].(string)
// Both ways of archiving must be refused: the status-only patch the UI
// sends, and a markdown save whose frontmatter says archived.
for name, patch := range map[string]map[string]any{
"status patch": {"status": "archived"},
"markdown save": {"markdown": agent("dep-child", "Child", "archived", 2)},
} {
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+childID, patch)
if res.code != http.StatusConflict {
t.Fatalf("%s: archiving a delegated-to agent: status %d, want 409 (%v)", name, res.code, res.body)
}
if body := fmt.Sprint(res.body); !strings.Contains(body, "dep-parent") {
t.Errorf("%s: the refusal did not name the dependent: %v", name, res.body)
}
}
// Refused, not half-applied.
res = r.as(r.admin, "GET", "/api/v1/agent-definitions/"+childID, nil)
if st, _ := res.record(t)["status"].(string); st != "published" {
t.Fatalf("child status after refused archives = %q, want published", st)
}
// A DRAFT parent does not pin the child. Move the parent to draft and the
// archive goes through: an abandoned experiment must not hold a
// production agent in place.
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+parentID, map[string]any{"status": "draft"})
if res.code != http.StatusOK {
t.Fatalf("draft the parent: status %d (%v)", res.code, res.body)
}
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+childID, map[string]any{"status": "archived"})
if res.code != http.StatusOK {
t.Fatalf("archive with only a draft dependent: status %d, want 200 (%v)", res.code, res.body)
}
}