v0.18.0: MCP entity-graph CRUD, lifecycle validation, curl -o /dev/null fix
- 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:
@@ -3,31 +3,22 @@ package httpapi
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/dtoro/oikos/internal/checkdefaults"
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ensureDefaultChecks derives an entity's default checks from the monitoring
|
||||
// kinds its type declares.
|
||||
// kinds its type declares. Thin wrapper over the shared db.EnsureEntityChecks
|
||||
// hook so the HTTP create/patch paths and the MCP entity-mutation tools stay
|
||||
// in lockstep.
|
||||
//
|
||||
// Note the ordering caveat: an entity created through the API usually has no
|
||||
// edges yet, so a type whose address comes from its host (a service) will
|
||||
// produce no checks on this pass. That gap is real and deliberately visible —
|
||||
// coverageSweep reports it, and the next inventory ingest fills it in once
|
||||
// the hosting edge exists.
|
||||
// Note the ordering caveat (carried from db.LoadTypeTree / checkdefaults.Ensure):
|
||||
// an entity created through the API usually has no edges yet, so a type whose
|
||||
// address comes from its host (a service) will produce no checks on this pass.
|
||||
// That gap is real and deliberately visible — coverageSweep reports it, and
|
||||
// the next inventory ingest fills it in once the hosting edge exists.
|
||||
func ensureDefaultChecks(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType, name string, attrsJSON []byte) error {
|
||||
tree, err := db.LoadTypeTree(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := checkdefaults.Ensure(ctx, tx, tree, checkdefaults.Target{
|
||||
ID: entityID, Slug: slug, Type: entityType, Name: name, Attrs: attrsJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
checkdefaults.LogResult(slug, entityType, res)
|
||||
return nil
|
||||
_, err := db.EnsureEntityChecks(ctx, tx, entityID, slug, entityType, name, attrsJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user