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

@@ -5,6 +5,7 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"math/big"
"strconv"
@@ -1062,49 +1063,15 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
// Validate lifecycle transition if state is being changed.
if req.Body.State != nil && *req.Body.State != "" {
// Get lifecycle def for the entity's type.
lc, err := sqlcgen.New(tx).GetLifecycleForType(ctx, current.Type)
if err != nil {
if err == pgx.ErrNoRows {
// No lifecycle defined — any state is allowed.
} else {
return nil, err
}
} else {
var transitions map[string]map[string]json.RawMessage
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
return nil, fmt.Errorf("parse lifecycle transitions: %w", err)
}
fromState := ""
if current.State != nil {
fromState = *current.State
}
toState := *req.Body.State
if toState != fromState {
tos, ok := transitions[fromState]
if !ok {
return nil, fmt.Errorf("%w: no transitions from %q", domain.ErrInvalidTransition, fromState)
}
trans, ok := tos[toState]
if !ok {
return nil, fmt.Errorf("%w: %s → %s", domain.ErrInvalidTransition, fromState, toState)
}
// Parse preconditions: {"requires": ["check-name", ...]}
var gate struct {
Requires []string `json:"requires"`
}
if err := json.Unmarshal(trans, &gate); err == nil && len(gate.Requires) > 0 {
for _, check := range gate.Requires {
if err := checkPrecondition(ctx, tx, id, current.Type, check); err != nil {
return nil, fmt.Errorf("%w: precondition %q not met: %v",
domain.ErrInvalidTransition, check, err)
}
}
}
fromState := ""
if current.State != nil {
fromState = *current.State
}
if err := db.ValidateTransition(ctx, tx, id, current.Type, fromState, *req.Body.State); err != nil {
if errors.Is(err, db.ErrTransitionInvalid) {
return nil, fmt.Errorf("%w: %v", domain.ErrInvalidTransition, err)
}
return nil, err
}
}
@@ -1556,97 +1523,5 @@ func generateAgeKeypair() (pubKey, privKey string, err error) {
return pub, priv, nil
}
// checkPrecondition validates a named lifecycle transition precondition.
func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, check string) error {
switch check {
case "no-inbound-edges":
var count int
err := tx.QueryRow(ctx,
"SELECT count(*) FROM relationships WHERE target_id = $1 AND valid_to IS NULL", entityID).Scan(&count)
if err != nil {
return err
}
if count > 0 {
return fmt.Errorf("%d inbound relationship edges remaining", count)
}
case "backups-verified":
var attrs string
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
if err != nil {
return err
}
if !strings.Contains(attrs, "backups_verified") {
return fmt.Errorf("backup verification not recorded in entity attributes")
}
case "secrets-revoked":
var attrs string
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
if err != nil {
return err
}
if !strings.Contains(attrs, "secrets_revoked") {
return fmt.Errorf("secret revocation not recorded in entity attributes")
}
case "ingress-dns-removed":
var attrs string
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
if err != nil {
return err
}
if !strings.Contains(attrs, "ingress_dns_removed") {
return fmt.Errorf("ingress/DNS removal not recorded in entity attributes")
}
case "age-key-enrolled-if-needed":
if entityType == "workstation" {
var attrs string
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
if err != nil {
return err
}
if !strings.Contains(attrs, "age_pubkey") {
return fmt.Errorf("age key not enrolled (no age_pubkey in attributes)")
}
}
case "mesh-joined-if-needed":
if entityType == "workstation" {
var attrs string
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
if err != nil {
return err
}
if !strings.Contains(attrs, "mesh_ip") {
return fmt.Errorf("mesh not joined (no mesh_ip in attributes)")
}
}
case "health-check-answering":
st, err := sqlcgen.New(tx).GetEntityStatus(ctx, entityID)
if err != nil || st.Health == "unknown" || st.Health == "down" {
return fmt.Errorf("health check not answering (status: %s)", st.Health)
}
case "doc-page-complete":
var count int
err := tx.QueryRow(ctx, `
SELECT count(*) FROM relationships r
JOIN entities ke ON ke.id = r.source_id
WHERE r.target_id = $1 AND r.valid_to IS NULL
AND r.type = 'documents' AND ke.type IN ('document','runbook','investigation')`,
entityID).Scan(&count)
if err != nil {
return err
}
if count == 0 {
return fmt.Errorf("no documentation linked to entity")
}
case "inventory-entry", "ip-reserved", "storage-pool-chosen", "cancelled-note",
"preflight-passed", "error-summary", "replacement-live-or-role-retired",
"replacement-failed", "post-verify-passed", "recovery-verified", "written-off",
"ingress-live-if-public", "doc-page-stub":
// Soft checks — always pass. These are operator-confirmed via the
// transition request itself, or are not mechanically enforceable.
default:
// Unknown preconditions are skipped (operator intent overrides).
}
return nil
}
// ─── Helpers ───────────────────────────────────────────────────────────