package db import ( "context" "encoding/json" "errors" "fmt" "github.com/dtoro/oikos/internal/db/sqlcgen" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // ErrTransitionInvalid is a sentinel returned by ValidateTransition when the // from→to pair is not a declared lifecycle transition or a precondition fails. // Callers test with errors.Is to distinguish semantic validation failures // (→ HTTP 409) from infrastructure errors (→ HTTP 500). var ErrTransitionInvalid = errors.New("invalid lifecycle transition") // ValidateTransition enforces an entity type's lifecycle: fromState → toState // must be a declared transition, and every precondition it lists must hold. A // type with no lifecycle defined allows any state. A no-op (fromState == // toState) passes immediately. // // Shared by the HTTP PATCH path and the MCP set_entity_state tool so both // surfaces apply identical lifecycle rules — previously only the HTTP path // validated transitions, so an agent changing state via MCP could skip the // graph's retire/deprecate guardrails entirely. func ValidateTransition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, fromState, toState string) error { if toState == fromState { return nil } lc, err := sqlcgen.New(tx).GetLifecycleForType(ctx, entityType) if err != nil { if err == pgx.ErrNoRows { return nil // no lifecycle defined → any state allowed } return err } var transitions map[string]map[string]json.RawMessage if err := json.Unmarshal(lc.Transitions, &transitions); err != nil { return fmt.Errorf("parse lifecycle transitions: %w", err) } tos, ok := transitions[fromState] if !ok { return fmt.Errorf("%w: no transitions defined from %q", ErrTransitionInvalid, fromState) } trans, ok := tos[toState] if !ok { return fmt.Errorf("%w: %s → %s is not a declared lifecycle transition", ErrTransitionInvalid, fromState, toState) } var gate struct { Requires []string `json:"requires"` } if err := json.Unmarshal(trans, &gate); err == nil { for _, check := range gate.Requires { if err := checkPrecondition(ctx, tx, entityID, entityType, check); err != nil { return fmt.Errorf("%w: precondition %q not met: %w", ErrTransitionInvalid, check, err) } } } return nil } // checkPrecondition evaluates one mechanical precondition named by a lifecycle // transition's `requires` list. Soft/operator-confirmed checks pass; unknown // checks are skipped (operator intent overrides). Moved here from httpapi so // both surfaces share one implementation. func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, check string) error { switch check { case "no-inbound-edges": var count int if err := tx.QueryRow(ctx, "SELECT count(*) FROM relationships WHERE target_id = $1 AND valid_to IS NULL", entityID).Scan(&count); err != nil { return err } if count > 0 { return fmt.Errorf("%d inbound relationship edges remaining", count) } case "backups-verified", "secrets-revoked", "ingress-dns-removed": attrs, err := fetchAttrs(ctx, tx, entityID) if err != nil { return err } want := map[string]string{ "backups-verified": "backups_verified", "secrets-revoked": "secrets_revoked", "ingress-dns-removed": "ingress_dns_removed", }[check] if !attrTruthy(attrs, want) { return fmt.Errorf("%s not recorded in entity attributes", want) } case "age-key-enrolled-if-needed": if entityType == "workstation" { attrs, err := fetchAttrs(ctx, tx, entityID) if err != nil { return err } if !attrTruthy(attrs, "age_pubkey") { return fmt.Errorf("age key not enrolled (no age_pubkey in attributes)") } } case "mesh-joined-if-needed": if entityType == "workstation" { attrs, err := fetchAttrs(ctx, tx, entityID) if err != nil { return err } if !attrTruthy(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" { h := "unknown" if err == nil { h = st.Health } return fmt.Errorf("health check not answering (status: %s)", h) } case "doc-page-complete": var count int if 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); 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", "un-deprecate-note", "write-off-note": // Soft checks — always pass. Operator-confirmed via the transition // request itself, or not mechanically enforceable. default: // Unknown preconditions are skipped (operator intent overrides). } return nil } // fetchAttrs loads an entity's JSONB attributes column as a decoded map. // Missing attributes decode to an empty map (every key absent). func fetchAttrs(ctx context.Context, tx pgx.Tx, entityID uuid.UUID) (map[string]any, error) { var raw string if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&raw); err != nil { return nil, err } var attrs map[string]any if err := json.Unmarshal([]byte(raw), &attrs); err != nil { return nil, fmt.Errorf("decode entity attributes: %w", err) } if attrs == nil { attrs = map[string]any{} } return attrs, nil } // attrTruthy reports whether key is present in attrs with a meaningful value. // It replaces substring matching on raw JSONB text: a previous strings.Contains // check treated {"backups_verified": false} as satisfied (the key text was // present) and bypassed the attributes GIN index. Booleans must be true; // strings must be non-empty; nil/absent fail. func attrTruthy(attrs map[string]any, key string) bool { v, ok := attrs[key] if !ok || v == nil { return false } switch t := v.(type) { case bool: return t case string: return t != "" default: return true // numbers, objects, arrays count as present } }