v0.18.0: MCP entity-graph CRUD, lifecycle validation, curl -o /dev/null fix
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

- create_entity, set_entity_state, end_relationship MCP tools
- update_entity_attributes now triggers check derivation via EnsureEntityChecks
- shared db.EnsureEntityChecks + db.ValidateTransition hooks (HTTP + MCP parity)
- curl -o /dev/null now classified read_only (was config_mutation)
- db.ErrTransitionInvalid sentinel for HTTP error-type accuracy
- SOUL.md: capability escalation, self-grounding, exploration budget rules
- Runbook: oikos check lifecycle for agent self-knowledge
This commit is contained in:
2026-08-04 08:52:08 +02:00
parent 058f1afcdc
commit 20adb89650
14 changed files with 1098 additions and 157 deletions

View File

@@ -0,0 +1,251 @@
package mcp
// Integration tests for the entity-mutation MCP tools (create_entity,
// update_entity_attributes), focused on the capability gap that stranded
// session 23da10db: entities mutated via MCP must derive/regenerate checks the
// same way the HTTP create/patch paths do. Guarded by OIKOS_TEST_DATABASE_URL
// (see internal/db/integration_test.go); run via `make test-db`.
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"os"
"strings"
"testing"
"github.com/dtoro/oikos/internal/checkdefaults"
"github.com/dtoro/oikos/internal/db"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// newTestPool mirrors internal/httpapi/api_test.go: a throwaway database,
// migrated and seeded with ontology/inventory/policy so create_entity's type
// validation and checkdefaults derivation have a real type tree to work
// against.
func newTestPool(t *testing.T) *db.Pool {
t.Helper()
baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
if baseURL == "" {
t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
}
ctx := context.Background()
admin, err := pgx.Connect(ctx, baseURL)
if err != nil {
t.Fatalf("connect admin: %v", err)
}
dbName := fmt.Sprintf("oikos_mcp_test_%08x", rand.Int63())
if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
admin.Close(ctx)
t.Fatalf("create test db: %v", err)
}
admin.Close(ctx)
qi := strings.Index(baseURL, "?")
base, params := baseURL, ""
if qi >= 0 {
base, params = baseURL[:qi], baseURL[qi:]
}
testURL := base[:strings.LastIndex(base, "/")+1] + dbName + params
pool, err := db.New(ctx, testURL)
if err != nil {
t.Fatalf("connect test db: %v", err)
}
t.Cleanup(func() {
pool.Close()
if admin, e := pgx.Connect(ctx, baseURL); e == nil {
admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
admin.Close(ctx)
}
})
if err := pool.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
for _, f := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
content, err := os.ReadFile("../../seeds/" + f)
if err != nil {
t.Fatalf("read seed %s: %v", f, err)
}
name := f
if err := pool.SeedIngest(ctx, name, content,
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
var err error
switch name {
case "ontology.yaml":
_, err = db.IngestOntologySeed(ctx, tx, data)
case "inventory.yaml":
_, err = db.IngestInventorySeed(ctx, tx, data)
case "policy.yaml":
_, err = db.IngestPolicySeed(ctx, tx, data)
}
return err
}); err != nil {
t.Fatalf("ingest %s: %v", f, err)
}
}
return pool
}
// callTool invokes a registered tool's handler in-process and returns its
// concatenated text result.
func callTool(t *testing.T, pool *db.Pool, name string, args map[string]any) string {
t.Helper()
var handler toolHandler
for _, r := range allTools(pool, uuid.Nil) {
if r.tool.Name == name {
handler = r.handler
break
}
}
if handler == nil {
t.Fatalf("tool %q not registered", name)
}
argsJSON, _ := json.Marshal(args)
res, err := handler(context.Background(), &mcp.CallToolRequest{Params: &mcp.CallToolParamsRaw{
Name: name,
Arguments: argsJSON,
}})
if err != nil {
t.Fatalf("tool %s returned error: %v", name, err)
}
var sb strings.Builder
for _, c := range res.Content {
if tc, ok := c.(*mcp.TextContent); ok {
sb.WriteString(tc.Text)
}
}
return sb.String()
}
// checkCountFor returns the number of derived check_defs targeting slug.
func checkCountFor(t *testing.T, pool *db.Pool, slug string) int {
t.Helper()
var n int
err := pool.QueryRow(context.Background(),
`SELECT count(*) FROM check_defs cd
JOIN entities e ON e.id = cd.target_id
WHERE e.slug = $1`, slug).Scan(&n)
if err != nil {
t.Fatalf("count check_defs for %s: %v", slug, err)
}
return n
}
// TestCreateEntity_DerivesChecks proves create_entity inserts an entity AND
// derives its default checks in one call (the HTTP create path did this; the
// MCP path previously could not create at all).
func TestCreateEntity_DerivesChecks(t *testing.T) {
pool := newTestPool(t)
slug := "service:mcp-create-test"
out := callTool(t, pool, "create_entity", map[string]any{
"type": "service",
"slug": slug,
"name": "mcp-create-test",
"attributes": `{"url":"https://mcp-create-test.example"}`,
})
if !strings.Contains(out, "Created "+slug) {
t.Fatalf("create_entity result = %q, want Created %s", out, slug)
}
if !strings.Contains(out, "Derived") {
t.Errorf("create_entity result = %q, want a Derived check summary", out)
}
if got := checkCountFor(t, pool, slug); got < 1 {
t.Errorf("check_defs targeting %s = %d, want >=1 (create did not derive checks)", slug, got)
}
}
// TestCreateEntity_DuplicateAndInvalid covers the guard rails: a repeat create
// is reported as "already exists" (not an error), and an unknown type is
// rejected with a clear message.
func TestCreateEntity_DuplicateAndInvalid(t *testing.T) {
pool := newTestPool(t)
if out := callTool(t, pool, "create_entity", map[string]any{
"type": "service", "slug": "service:mcp-dup", "name": "mcp-dup",
}); !strings.Contains(out, "Created service:mcp-dup") {
t.Fatalf("first create = %q", out)
}
if out := callTool(t, pool, "create_entity", map[string]any{
"type": "service", "slug": "service:mcp-dup", "name": "mcp-dup",
}); !strings.Contains(out, "already exists") {
t.Errorf("duplicate create = %q, want 'already exists'", out)
}
if out := callTool(t, pool, "create_entity", map[string]any{
"type": "no-such-type", "slug": "no-such-type:x", "name": "x",
}); !strings.Contains(out, "not found in ontology") {
t.Errorf("unknown type = %q, want 'not found in ontology'", out)
}
}
// TestUpdateEntityAttributes_RegeneratesChecks is the regression guard for the
// haos session: setting an entity's `monitoring` attribute via MCP must
// regenerate checks. Before this fix the MCP update path skipped
// ensureDefaultChecks, so flipping monitoring produced nothing.
func TestUpdateEntityAttributes_RegeneratesChecks(t *testing.T) {
pool := newTestPool(t)
slug := "service:mcp-regen-test"
// Create with monitoring:none — no checks derived.
if out := callTool(t, pool, "create_entity", map[string]any{
"type": "service", "slug": slug, "name": "mcp-regen-test",
"attributes": `{"monitoring":"none","url":"https://mcp-regen.example"}`,
}); !strings.Contains(out, "Created "+slug) {
t.Fatalf("create = %q", out)
}
if got := checkCountFor(t, pool, slug); got != 0 {
t.Fatalf("check_defs with monitoring:none = %d, want 0", got)
}
// Flip monitoring to [http] via update_entity_attributes — checks must
// regenerate. This is exactly what failed for service:haos.
out := callTool(t, pool, "update_entity_attributes", map[string]any{
"slug": slug,
"attributes": `{"monitoring":["http"]}`,
})
if !strings.Contains(out, "Updated "+slug) {
t.Fatalf("update result = %q, want Updated %s", out, slug)
}
if !strings.Contains(out, "Derived") {
t.Errorf("update result = %q, want a Derived check summary (regeneration)", out)
}
if got := checkCountFor(t, pool, slug); got < 1 {
t.Errorf("check_defs after monitoring:[http] = %d, want >=1 (MCP update did not regenerate checks)", got)
}
}
// TestUpdateEntityAttributes_NotFound keeps the existing error contract.
func TestUpdateEntityAttributes_NotFound(t *testing.T) {
pool := newTestPool(t)
out := callTool(t, pool, "update_entity_attributes", map[string]any{
"slug": "service:does-not-exist",
"attributes": `{"x":1}`,
})
if !strings.Contains(out, "not found") {
t.Errorf("update missing entity = %q, want 'not found'", out)
}
}
// TestFormatCheckResult is a pure unit test for the result-message helper, so
// the formatting contract holds even when the DB is unavailable.
func TestFormatCheckResult(t *testing.T) {
if got := formatCheckResult(checkdefaults.Result{Created: 2}); !strings.Contains(got, "Derived 2 check") {
t.Errorf("created-only = %q, want Derived 2", got)
}
got := formatCheckResult(checkdefaults.Result{Created: 1, Skipped: []checkdefaults.Skip{{Kind: "process", Reason: "no host"}}})
if !strings.Contains(got, "Derived 1 check") || !strings.Contains(got, "Skipped process") || !strings.Contains(got, "no host") {
t.Errorf("created+skipped = %q", got)
}
if got := formatCheckResult(checkdefaults.Result{Undeclared: true}); !strings.Contains(got, "no monitoring") {
t.Errorf("undeclared = %q, want no-monitoring hint", got)
}
if formatCreateResult("a", "b", checkdefaults.Result{Created: 0}) != "Created a (b)." {
t.Error("create result with no checks should have no suffix")
}
}

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/dtoro/oikos/internal/audit"
"github.com/dtoro/oikos/internal/checkdefaults"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/policy"
"github.com/google/uuid"
@@ -173,6 +174,109 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
return upsertKnowledge(ctx, pool, args)
}},
{tool: &mcp.Tool{Name: "create_entity", Description: "Create a new entity in the knowledge graph — the creation half alongside update_entity_attributes (which only updates EXISTING entities). Use it when a task needs an entity that does not exist yet: a new check (check:<kind>:<target>:<n>), an ingress (ingress:<host>), a cert (cert:<host>), a service, a host/LXC/VM, etc. After inserting, it derives default checks from the entity type's monitoring spec (same as a seed ingest), so creating a checkable entity wires its monitoring in one call. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). If the slug already exists it returns 'already exists' — then use update_entity_attributes to change it.",
InputSchema: objSchema(
prop{"type", "string", "Entity type — must already exist in the ontology and not be abstract (e.g. service, lxc, host, vm, check, ingress, cert, dns)."},
prop{"name", "string", "Human-readable name (e.g. 'HAOS http service check')."},
prop{"slug", "string", "Entity slug (e.g. check:http:service:haos:0, ingress:home.hubris.network). If omitted, defaults to <type>:<name>."},
prop{"attributes", "string", "JSON object string of attributes, e.g. {\"check_type\":\"http:service\",\"target\":\"service:haos\",\"port\":\"8123\"}. Optional."},
prop{"state", "string", "Lifecycle state. Optional; defaults to the type's lifecycle default_state."},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
entityType, _ := args["type"].(string)
name, _ := args["name"].(string)
slug, _ := args["slug"].(string)
if slug == "" && entityType != "" && name != "" {
slug = entityType + ":" + name
}
if entityType == "" || name == "" || slug == "" {
return textResult("error: type and name are required (slug defaults to <type>:<name>)"), nil
}
attrsStr, _ := args["attributes"].(string)
attrs := map[string]any{}
if attrsStr != "" {
if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil {
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
}
}
attrsJSON, _ := json.Marshal(attrs)
stateStr, _ := args["state"].(string)
tx, err := pool.Begin(ctx)
if err != nil {
return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil
}
defer tx.Rollback(ctx)
// Validate the type exists and is concrete (mirror httpapi.CreateEntity).
var isAbstract bool
if err := tx.QueryRow(ctx, `SELECT is_abstract FROM entity_types WHERE name = $1`, entityType).Scan(&isAbstract); err != nil {
return textResult(fmt.Sprintf("error: entity type %q not found in ontology", entityType)), nil
}
if isAbstract {
return textResult(fmt.Sprintf("error: type %q is abstract — pick a concrete subtype", entityType)), nil
}
// Default state from the type's lifecycle unless the caller
// supplied one. Caller-supplied states are validated against
// the lifecycle's declared states — a create_entity bypass of
// lifecycle guardrails would let an agent create in a terminal
// state (destroyed) without satisfying the preconditions that
// set_entity_state enforces for the same transition.
var state *string
var lsDefault, statesRaw string
if err := tx.QueryRow(ctx, `SELECT coalesce(ld.default_state,''), coalesce(ld.states::text,'')
FROM lifecycle_defs ld
JOIN entity_types et ON et.lifecycle_id = ld.id
WHERE et.name = $1`, entityType).Scan(&lsDefault, &statesRaw); err == nil {
var validStates []string
json.Unmarshal([]byte(statesRaw), &validStates)
if stateStr != "" {
found := false
for _, s := range validStates {
if s == stateStr {
found = true
break
}
}
if !found && len(validStates) > 0 {
return textResult(fmt.Sprintf("error: state %q not declared in %s lifecycle (states: %s). Use the default (%s) or omit state.", stateStr, entityType, strings.Join(validStates, ","), lsDefault)), nil
}
state = &stateStr
} else if lsDefault != "" {
state = &lsDefault
}
}
id, err := uuid.NewV7()
if err != nil {
return textResult(fmt.Sprintf("error: gen id: %v", err)), nil
}
var createdName string
if err := tx.QueryRow(ctx, `
INSERT INTO entities (id, slug, type, name, state, attributes)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING name`,
id, slug, entityType, name, state, attrsJSON).Scan(&createdName); err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return textResult(fmt.Sprintf("Entity %q already exists — use update_entity_attributes to change it.", slug)), nil
}
return textResult(fmt.Sprintf("error creating %s: %v", slug, err)), nil
}
res, derr := db.EnsureEntityChecks(ctx, tx, id, slug, entityType, createdName, attrsJSON)
if derr != nil {
return textResult(fmt.Sprintf("error deriving checks for %s: %v", slug, derr)), nil
}
if cerr := tx.Commit(ctx); cerr != nil {
return textResult(fmt.Sprintf("error committing %s: %v", slug, cerr)), nil
}
return textResult(formatCreateResult(slug, entityType, res)), nil
}},
{tool: &mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.",
InputSchema: objSchema(
prop{"slug", "string", "Entity slug to update (e.g. lxc:typetype, host:strong)."},
@@ -190,7 +294,18 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
}
attrsJSON, _ := json.Marshal(attrs)
ct, err := pool.Exec(ctx, `
// Run the merge + check regeneration in one transaction so the
// derived checks always see the post-merge attributes. Mirrors
// httpapi.PatchEntity; without this, setting an entity's
// `monitoring` attribute via MCP silently produced no checks.
tx, err := pool.Begin(ctx)
if err != nil {
return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil
}
defer tx.Rollback(ctx)
ct, err := tx.Exec(ctx, `
UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now()
WHERE slug = $1`, slug, string(attrsJSON))
if err != nil {
@@ -199,7 +314,65 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
if ct.RowsAffected() == 0 {
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
}
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).", slug, len(attrs))), nil
var id uuid.UUID
var entityType, name string
var mergedAttrs []byte
if err := tx.QueryRow(ctx, `SELECT id, type, name, attributes FROM entities WHERE slug = $1`, slug).
Scan(&id, &entityType, &name, &mergedAttrs); err != nil {
return textResult(fmt.Sprintf("error reloading %s: %v", slug, err)), nil
}
res, cerr := db.EnsureEntityChecks(ctx, tx, id, slug, entityType, name, mergedAttrs)
if cerr != nil {
return textResult(fmt.Sprintf("error deriving checks for %s: %v", slug, cerr)), nil
}
if cerr := tx.Commit(ctx); cerr != nil {
return textResult(fmt.Sprintf("error committing %s: %v", slug, cerr)), nil
}
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).%s", slug, len(attrs), formatCheckResult(res))), nil
}},
{tool: &mcp.Tool{Name: "set_entity_state", Description: "Transition an entity to a new lifecycle state — the entity-graph \"delete\" surface, since this system never hard-deletes entities. Use retire/deprecate to take an entity out of service, destroy for terminal removal, or active to revive. The target state must be a declared transition in the entity type's lifecycle (e.g. active→deprecated, deprecated→active); preconditions (no inbound edges, backups verified, etc.) are enforced — an error tells you what's blocking. Does NOT require approval (knowledge-graph mutation, not live infrastructure).",
InputSchema: objSchema(
prop{"slug", "string", "Entity slug."},
prop{"state", "string", "Target lifecycle state."},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["slug"].(string)
targetState, _ := args["state"].(string)
if slug == "" || targetState == "" {
return textResult("error: slug and state are required"), nil
}
tx, err := pool.Begin(ctx)
if err != nil {
return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil
}
defer tx.Rollback(ctx)
var id uuid.UUID
var entityType, currentState string
if err := tx.QueryRow(ctx, `SELECT id, type, coalesce(state,'') FROM entities WHERE slug = $1`, slug).
Scan(&id, &entityType, &currentState); err != nil {
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
}
if err := db.ValidateTransition(ctx, tx, id, entityType, currentState, targetState); err != nil {
return textResult(fmt.Sprintf("error: %v", err)), nil
}
ct, err := tx.Exec(ctx, `UPDATE entities SET state = $2, updated_at = now() WHERE id = $1`, id, targetState)
if err != nil {
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
}
if ct.RowsAffected() == 0 {
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
}
if err := tx.Commit(ctx); err != nil {
return textResult(fmt.Sprintf("error: commit: %v", err)), nil
}
return textResult(fmt.Sprintf("Transitioned %s: %s → %s.", slug, currentState, targetState)), nil
}},
{tool: &mcp.Tool{Name: "create_relationship", Description: "Record a relationship you discovered between two entities — the graph-structure half of keeping the knowledge base current (alongside update_entity_attributes and upsert_knowledge). Use it when you learn that one entity depends on, hosts, routes to, etc. another, and that edge isn't in the graph yet. type must be an existing relationship type (see get_relations output on similar entities for examples: hosts, provides, depends-on, configured-by, about, documents, ...). Idempotent — re-calling the same source/target/type is a no-op. Does NOT require approval.",
@@ -236,6 +409,40 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil
}},
{tool: &mcp.Tool{Name: "end_relationship", Description: "End an existing relationship (soft-delete by setting valid_to) — the graph-structure \"delete\" surface. Use it when you discover an edge is no longer true (a service moved hosts, a route was removed, a dependency dissolved). The edge is kept for history; only the currently-active edge is ended. Idempotent — ending an already-ended or non-existent edge is a no-op. Does NOT require approval.",
InputSchema: objSchema(
prop{"source", "string", "Source entity slug."},
prop{"target", "string", "Target entity slug."},
prop{"type", "string", "Relationship type name."},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
source, _ := args["source"].(string)
target, _ := args["target"].(string)
relType, _ := args["type"].(string)
if source == "" || target == "" || relType == "" {
return textResult("error: source, target, and type are required"), nil
}
var sourceID, targetID uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", source).Scan(&sourceID); err != nil {
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
}
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", target).Scan(&targetID); err != nil {
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
}
ct, err := pool.Exec(ctx, `
UPDATE relationships SET valid_to = now()
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL`,
sourceID, targetID, relType)
if err != nil {
return textResult(fmt.Sprintf("error ending relationship: %v", err)), nil
}
if ct.RowsAffected() == 0 {
return textResult(fmt.Sprintf("No active relationship %s —%s→ %s found.", source, relType, target)), nil
}
return textResult(fmt.Sprintf("Ended: %s —%s→ %s.", source, relType, target)), nil
}},
{tool: &mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics",
InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -819,3 +1026,25 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
}},
}
}
// formatCheckResult renders a human-readable summary of what check derivation
// did, appended to a base status message. Shared by create_entity and
// update_entity_attributes so both surface the same check-regeneration signal
// (created / undeclared / skipped) to the agent.
func formatCheckResult(res checkdefaults.Result) string {
var b strings.Builder
if res.Created > 0 {
fmt.Fprintf(&b, " Derived %d check(s).", res.Created)
}
if res.Undeclared {
b.WriteString(" Type declares no monitoring — no checks derived (set the entity's `monitoring` attribute and call update_entity_attributes to regenerate).")
}
for _, s := range res.Skipped {
fmt.Fprintf(&b, " Skipped %s (%s).", s.Kind, s.Reason)
}
return b.String()
}
func formatCreateResult(slug, entityType string, res checkdefaults.Result) string {
return fmt.Sprintf("Created %s (%s).%s", slug, entityType, formatCheckResult(res))
}