From 18cb79caf972b9174355b4b2ea2eaeaae4f970b7 Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 7 Jul 2026 00:17:15 +0200 Subject: [PATCH] oikos phase 0: ontology + inventory + policy seeds, OpenAPI contract, ADRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - seeds/ontology.yaml: 59 entity types (5 abstract, is-a hierarchy), 46 relationship types with cardinality, 6 lifecycles with terminal states and named precondition checks - seeds/inventory.yaml: 110 entities / 142 relationships translated from legacy inventory.yaml (fleet, services, ingress, storage, governance, archaeology); thin spots marked for backfill - seeds/policy.yaml: 4 risk classes, 27 approval rules (hierarchy-aware, per-entity overrides), autonomy kill-switch off (cold start) - api/openapi.yaml: full v1 REST contract (40 paths), RFC 9457 errors, cursor pagination, idempotency, ETag/If-Match, scopes; redocly-clean - docs/adr/0001-0010: initial architecture decision records - scripts/validate-seeds.py: Phase 0 gate — hierarchy, lifecycles, endpoints, cardinality, policy cross-refs (0 errors) - plan: layer CHECK gains 'meta' (root type), cardinality gains 'many-to-one' Co-Authored-By: Claude Fable 5 --- api/openapi.yaml | 1636 +++++++++++++++++ api/redocly.yaml | 7 + docs/adr/0001-go-single-binary.md | 23 + .../0002-postgres-timescale-only-datastore.md | 25 + .../adr/0003-db-native-ontology-yaml-seeds.md | 23 + docs/adr/0004-openapi-first.md | 21 + docs/adr/0005-uuidv7-plus-slug-identity.md | 18 + docs/adr/0006-learning-proposal-only.md | 23 + docs/adr/0007-threat-model.md | 27 + docs/adr/0008-forward-only-migrations.md | 20 + docs/adr/0009-sse-over-websocket.md | 19 + docs/adr/0010-infisical-with-sops-fallback.md | 20 + docs/adr/README.md | 18 + ...idate-oikos-control-plane-onto-mac-mini.md | 7 +- scripts/validate-seeds.py | 284 +++ seeds/inventory.yaml | 534 ++++++ seeds/ontology.yaml | 936 ++++++++++ seeds/policy.yaml | 103 ++ 18 files changed, 3741 insertions(+), 3 deletions(-) create mode 100644 api/openapi.yaml create mode 100644 api/redocly.yaml create mode 100644 docs/adr/0001-go-single-binary.md create mode 100644 docs/adr/0002-postgres-timescale-only-datastore.md create mode 100644 docs/adr/0003-db-native-ontology-yaml-seeds.md create mode 100644 docs/adr/0004-openapi-first.md create mode 100644 docs/adr/0005-uuidv7-plus-slug-identity.md create mode 100644 docs/adr/0006-learning-proposal-only.md create mode 100644 docs/adr/0007-threat-model.md create mode 100644 docs/adr/0008-forward-only-migrations.md create mode 100644 docs/adr/0009-sse-over-websocket.md create mode 100644 docs/adr/0010-infisical-with-sops-fallback.md create mode 100644 docs/adr/README.md create mode 100644 scripts/validate-seeds.py create mode 100644 seeds/inventory.yaml create mode 100644 seeds/ontology.yaml create mode 100644 seeds/policy.yaml diff --git a/api/openapi.yaml b/api/openapi.yaml new file mode 100644 index 0000000..121fe06 --- /dev/null +++ b/api/openapi.yaml @@ -0,0 +1,1636 @@ +openapi: 3.1.0 +info: + title: Oikos API + version: 1.0.0 + contact: {name: dtoro} + license: {name: Private, identifier: LicenseRef-Private} + description: | + Control-plane API for the Oikos homelab OS. This file is the **source of + truth** (contract-first): Go server stubs are generated with oapi-codegen, + clients (homelab CLI, future UIs) from the same spec. + + Conventions (plan R3-3): + - Errors are RFC 9457 `application/problem+json`. + - Lists use `{items, next_cursor}` with cursor pagination (default limit 50, max 200). + - Unsafe POSTs accept `Idempotency-Key` (24h replay window). + - Mutable resources carry `version`; GET returns `ETag`, PATCH requires `If-Match` (412 on mismatch). + - Timestamps are RFC 3339 UTC. + - Entities are addressable by UUID or slug (`host:hubris`). + + The MCP interface (streamable HTTP, official Go SDK) is mounted at `/mcp` + on the same binary and is out of scope for this document; its tools wrap + the same service layer as these endpoints. +servers: + - url: /api/v1 + +tags: + - name: entities + description: "Inventory graph — entities and relationships" + - name: ontology + description: "Entity types, relationship types, lifecycles" + - name: signals + description: "Signals and checks (observe)" + - name: executions + description: "Executions, classifications, approvals (decide/act)" + - name: learning + description: "Patterns and skills (learn)" + - name: policy + description: "Risk classes, approval rules, autonomy" + - name: knowledge + description: "Knowledge graph search" + - name: observability + description: "Metrics, trends, audit, events, health" + - name: system + description: "Export, health" + +security: + - bearerAuth: [] + +paths: + + # ─── Entities ───────────────────────────────────────────────────── + /entities: + get: + tags: [entities] + operationId: listEntities + summary: List entities + x-required-scope: viewer + parameters: + - {name: type, in: query, schema: {type: string}, description: Filter by entity type (includes descendants)} + - {name: state, in: query, schema: {type: string}} + - {name: domain, in: query, schema: {type: string}} + - {name: layer, in: query, schema: {type: string}} + - {name: q, in: query, schema: {type: string}, description: Substring match on slug/name} + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: Entity list + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/Entity'}} + next_cursor: {type: [string, "null"]} + default: {$ref: '#/components/responses/Problem'} + post: + tags: [entities] + operationId: createEntity + summary: Create an entity + x-required-scope: operator + parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/EntityCreate'} + responses: + '201': + description: Created + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: + application/json: + schema: {$ref: '#/components/schemas/Entity'} + default: {$ref: '#/components/responses/Problem'} + + /entities/{id}: + parameters: [{$ref: '#/components/parameters/EntityId'}] + get: + tags: [entities] + operationId: getEntity + summary: Get entity by UUID or slug + x-required-scope: viewer + responses: + '200': + description: Entity detail + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: + application/json: + schema: {$ref: '#/components/schemas/Entity'} + default: {$ref: '#/components/responses/Problem'} + patch: + tags: [entities] + operationId: patchEntity + summary: Update attributes or transition lifecycle state + description: | + Lifecycle transitions are validated against the type's lifecycle_def; + illegal transitions return 409 (invalid-transition). Policy-gated + actions may return 403 (approval-required). + x-required-scope: operator + parameters: [{$ref: '#/components/parameters/IfMatch'}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/EntityPatch'} + responses: + '200': + description: Updated entity + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: + application/json: + schema: {$ref: '#/components/schemas/Entity'} + default: {$ref: '#/components/responses/Problem'} + + /entities/{id}/relations: + parameters: [{$ref: '#/components/parameters/EntityId'}] + get: + tags: [entities] + operationId: getEntityRelations + summary: Direct relationships of an entity (both directions) + x-required-scope: viewer + parameters: + - {name: rel_type, in: query, schema: {type: string}} + - {name: direction, in: query, schema: {type: string, enum: [out, in, both], default: both}} + responses: + '200': + description: Relationships + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/Relationship'}} + default: {$ref: '#/components/responses/Problem'} + + /relationships: + post: + tags: [entities] + operationId: createRelationship + summary: Create a relationship edge + description: Endpoint types validated against relationship_types (hierarchy-aware); cardinality enforced. + x-required-scope: operator + parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/RelationshipCreate'} + responses: + '201': + description: Created + content: + application/json: + schema: {$ref: '#/components/schemas/Relationship'} + default: {$ref: '#/components/responses/Problem'} + delete: + tags: [entities] + operationId: endRelationship + summary: End a relationship (sets valid_to; the edge is kept for history) + x-required-scope: operator + parameters: + - {name: source, in: query, required: true, schema: {type: string}} + - {name: target, in: query, required: true, schema: {type: string}} + - {name: rel_type, in: query, required: true, schema: {type: string}} + responses: + '204': {description: Relationship ended} + default: {$ref: '#/components/responses/Problem'} + + /graph: + get: + tags: [entities] + operationId: getGraph + summary: Subgraph for visualization (nodes + edges) + x-required-scope: viewer + parameters: + - {name: root, in: query, schema: {type: string}, description: Start entity (UUID or slug); omit for whole graph (capped)} + - {name: depth, in: query, schema: {type: integer, default: 2, maximum: 5}} + - {name: rel_type, in: query, schema: {type: array, items: {type: string}}, style: form, explode: true} + responses: + '200': + description: Graph view + content: + application/json: + schema: {$ref: '#/components/schemas/GraphView'} + default: {$ref: '#/components/responses/Problem'} + + /entities/{id}/blast-radius: + parameters: [{$ref: '#/components/parameters/EntityId'}] + get: + tags: [entities] + operationId: getBlastRadius + summary: Entities affected if this entity fails + x-required-scope: viewer + parameters: + - {name: depth, in: query, schema: {type: integer, default: 3, maximum: 5}} + responses: + '200': + description: Affected entities with graph distance + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + type: object + required: [entity, depth] + properties: + entity: {$ref: '#/components/schemas/Entity'} + depth: {type: integer} + default: {$ref: '#/components/responses/Problem'} + + # ─── Ontology ───────────────────────────────────────────────────── + /ontology: + get: + tags: [ontology] + operationId: getOntology + summary: Full ontology — entity types, relationship types, lifecycles + x-required-scope: viewer + responses: + '200': + description: Ontology + content: + application/json: + schema: + type: object + required: [entity_types, relationship_types, lifecycles] + properties: + entity_types: {type: array, items: {$ref: '#/components/schemas/EntityType'}} + relationship_types: {type: array, items: {$ref: '#/components/schemas/RelationshipType'}} + lifecycles: {type: array, items: {$ref: '#/components/schemas/LifecycleDef'}} + default: {$ref: '#/components/responses/Problem'} + + /ontology/entity-types: + post: + tags: [ontology] + operationId: createEntityType + summary: Extend the ontology with a new entity type + description: Policy-gated as config_mutation (creates a meta-approval when required). + x-required-scope: operator + parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/EntityTypeCreate'} + responses: + '201': + description: Created + content: + application/json: + schema: {$ref: '#/components/schemas/EntityType'} + '202': {$ref: '#/components/responses/PendingApproval'} + default: {$ref: '#/components/responses/Problem'} + + /ontology/entity-types/{name}: + parameters: + - {name: name, in: path, required: true, schema: {type: string}} + patch: + tags: [ontology] + operationId: patchEntityType + summary: Update or deprecate an entity type + description: Hard delete is not supported while instances exist — deprecate instead (plan D3). + x-required-scope: operator + parameters: [{$ref: '#/components/parameters/IfMatch'}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/EntityTypePatch'} + responses: + '200': + description: Updated + content: + application/json: + schema: {$ref: '#/components/schemas/EntityType'} + '202': {$ref: '#/components/responses/PendingApproval'} + default: {$ref: '#/components/responses/Problem'} + + # ─── Signals + checks ───────────────────────────────────────────── + /signals: + get: + tags: [signals] + operationId: listSignals + summary: List signals + x-required-scope: viewer + parameters: + - {name: state, in: query, schema: {type: string}} + - {name: severity, in: query, schema: {type: string, enum: [info, warning, critical]}} + - {name: entity_id, in: query, schema: {type: string}} + - {name: kind, in: query, schema: {type: string}} + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: Signals + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/Signal'}} + next_cursor: {type: [string, "null"]} + default: {$ref: '#/components/responses/Problem'} + + /signals/{id}/ack: + parameters: [{$ref: '#/components/parameters/EntityId'}] + post: + tags: [signals] + operationId: ackSignal + summary: Acknowledge a signal + x-required-scope: operator + responses: + '200': {$ref: '#/components/responses/SignalUpdated'} + default: {$ref: '#/components/responses/Problem'} + + /signals/{id}/resolve: + parameters: [{$ref: '#/components/parameters/EntityId'}] + post: + tags: [signals] + operationId: resolveSignal + summary: Resolve a signal manually + x-required-scope: operator + requestBody: + content: + application/json: + schema: + type: object + properties: {note: {type: string}} + responses: + '200': {$ref: '#/components/responses/SignalUpdated'} + default: {$ref: '#/components/responses/Problem'} + + /signals/{id}/mute: + parameters: [{$ref: '#/components/parameters/EntityId'}] + post: + tags: [signals] + operationId: muteSignal + summary: Mute a signal for a TTL + x-required-scope: operator + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [mute_until] + properties: + mute_until: {type: string, format: date-time} + note: {type: string} + responses: + '200': {$ref: '#/components/responses/SignalUpdated'} + default: {$ref: '#/components/responses/Problem'} + + /checks: + get: + tags: [signals] + operationId: listChecks + summary: List check definitions + x-required-scope: viewer + parameters: + - {name: kind, in: query, schema: {type: string}} + - {name: target, in: query, schema: {type: string}} + - {name: enabled, in: query, schema: {type: boolean}} + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: Checks + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/Check'}} + next_cursor: {type: [string, "null"]} + default: {$ref: '#/components/responses/Problem'} + post: + tags: [signals] + operationId: createCheck + summary: Create a check (checks-as-data, plan R3-7) + x-required-scope: operator + parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CheckCreate'} + responses: + '201': + description: Created + content: + application/json: + schema: {$ref: '#/components/schemas/Check'} + default: {$ref: '#/components/responses/Problem'} + + /checks/{id}: + parameters: [{$ref: '#/components/parameters/EntityId'}] + patch: + tags: [signals] + operationId: patchCheck + summary: Update or disable a check + x-required-scope: operator + parameters: [{$ref: '#/components/parameters/IfMatch'}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CheckPatch'} + responses: + '200': + description: Updated + content: + application/json: + schema: {$ref: '#/components/schemas/Check'} + default: {$ref: '#/components/responses/Problem'} + + # ─── Executions / classifications / approvals ───────────────────── + /executions: + get: + tags: [executions] + operationId: listExecutions + summary: List executions + x-required-scope: viewer + parameters: + - {name: status, in: query, schema: {type: string}} + - {name: target, in: query, schema: {type: string}} + - {name: action, in: query, schema: {type: string}} + - {name: correlation_id, in: query, schema: {type: string}} + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: Executions + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/Execution'}} + next_cursor: {type: [string, "null"]} + default: {$ref: '#/components/responses/Problem'} + post: + tags: [executions] + operationId: requestExecution + summary: Request an execution (classify → approval check → enqueue) + description: | + The handler classifies the (entity, action), checks policy/autonomy, + and either enqueues the execution (auto-approved) or creates an + approval request and returns the execution in `proposed` state. + Also exposed to the agent role — this is the ONLY way agents act. + x-required-scope: agent + parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ExecutionRequest'} + responses: + '201': + description: Execution created (may be pending approval) + content: + application/json: + schema: {$ref: '#/components/schemas/Execution'} + default: {$ref: '#/components/responses/Problem'} + + /executions/{id}: + parameters: [{$ref: '#/components/parameters/EntityId'}] + get: + tags: [executions] + operationId: getExecution + summary: Execution status + result + x-required-scope: viewer + responses: + '200': + description: Execution + content: + application/json: + schema: {$ref: '#/components/schemas/Execution'} + default: {$ref: '#/components/responses/Problem'} + + /executions/{id}/cancel: + parameters: [{$ref: '#/components/parameters/EntityId'}] + post: + tags: [executions] + operationId: cancelExecution + summary: Cancel a proposed/executing execution + x-required-scope: operator + responses: + '200': + description: Cancelled + content: + application/json: + schema: {$ref: '#/components/schemas/Execution'} + default: {$ref: '#/components/responses/Problem'} + + /classifications: + get: + tags: [executions] + operationId: listClassifications + summary: Classifier decisions (the autonomous-decision audit trail) + x-required-scope: viewer + parameters: + - {name: signal_id, in: query, schema: {type: string}} + - {name: entity_id, in: query, schema: {type: string}} + - {name: route, in: query, schema: {type: string, enum: [auto-act, escalate, hold]}} + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: Classifications + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/Classification'}} + next_cursor: {type: [string, "null"]} + default: {$ref: '#/components/responses/Problem'} + + /approvals: + get: + tags: [executions] + operationId: listApprovals + summary: List approvals + x-required-scope: viewer + parameters: + - {name: status, in: query, schema: {type: string, enum: [pending, approved, denied, expired, revoked]}} + - {name: kind, in: query, schema: {type: string, enum: [execution, policy-change, pattern-activation]}} + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: Approvals + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/Approval'}} + next_cursor: {type: [string, "null"]} + default: {$ref: '#/components/responses/Problem'} + + /approvals/{id}/decision: + parameters: [{$ref: '#/components/parameters/EntityId'}] + post: + tags: [executions] + operationId: decideApproval + summary: Approve or deny (single-use token verified server-side) + x-required-scope: operator + parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [decision] + properties: + decision: {type: string, enum: [approve, deny, revoke]} + note: {type: string} + responses: + '200': + description: Decision recorded + content: + application/json: + schema: {$ref: '#/components/schemas/Approval'} + default: {$ref: '#/components/responses/Problem'} + + # ─── Learning ───────────────────────────────────────────────────── + /patterns: + get: + tags: [learning] + operationId: listPatterns + summary: List patterns + x-required-scope: viewer + parameters: + - {name: entity_type, in: query, schema: {type: string}} + - {name: action, in: query, schema: {type: string}} + - {name: status, in: query, schema: {type: string}} + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: Patterns + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/Pattern'}} + next_cursor: {type: [string, "null"]} + default: {$ref: '#/components/responses/Problem'} + + /patterns/{id}: + parameters: [{$ref: '#/components/parameters/EntityId'}] + patch: + tags: [learning] + operationId: patchPattern + summary: Transition a pattern (activate / invalidate / deprecate) + description: Operator safety valve (plan SG7). Activation is policy-gated config_mutation (S4). + x-required-scope: operator + parameters: [{$ref: '#/components/parameters/IfMatch'}] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + status: {type: string, enum: [validated, active, deprecated, invalidated]} + quarantined: {type: boolean} + note: {type: string} + responses: + '200': + description: Updated + content: + application/json: + schema: {$ref: '#/components/schemas/Pattern'} + '202': {$ref: '#/components/responses/PendingApproval'} + default: {$ref: '#/components/responses/Problem'} + + /skills: + get: + tags: [learning] + operationId: listSkills + summary: List skills (latest version per skill) + x-required-scope: viewer + parameters: + - {name: applies_to, in: query, schema: {type: string}} + - {name: action, in: query, schema: {type: string}} + - {name: status, in: query, schema: {type: string}} + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: Skills + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/Skill'}} + next_cursor: {type: [string, "null"]} + default: {$ref: '#/components/responses/Problem'} + + /skills/{id}: + parameters: [{$ref: '#/components/parameters/EntityId'}] + patch: + tags: [learning] + operationId: patchSkill + summary: Transition a skill or pin a version + x-required-scope: operator + parameters: [{$ref: '#/components/parameters/IfMatch'}] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + status: {type: string, enum: [tested, active, deprecated]} + pinned_version: {type: integer} + note: {type: string} + responses: + '200': + description: Updated + content: + application/json: + schema: {$ref: '#/components/schemas/Skill'} + '202': {$ref: '#/components/responses/PendingApproval'} + default: {$ref: '#/components/responses/Problem'} + + /skills/{id}/versions: + parameters: [{$ref: '#/components/parameters/EntityId'}] + get: + tags: [learning] + operationId: listSkillVersions + summary: Version history of a skill + x-required-scope: viewer + responses: + '200': + description: Versions + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/Skill'}} + default: {$ref: '#/components/responses/Problem'} + + # ─── Policy ─────────────────────────────────────────────────────── + /policy/risk-classes: + get: + tags: [policy] + operationId: listRiskClasses + summary: List risk classes + x-required-scope: viewer + responses: + '200': + description: Risk classes + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/RiskClass'}} + default: {$ref: '#/components/responses/Problem'} + + /policy/approval-rules: + get: + tags: [policy] + operationId: listApprovalRules + summary: List approval rules + x-required-scope: viewer + responses: + '200': + description: Rules + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/ApprovalRule'}} + default: {$ref: '#/components/responses/Problem'} + post: + tags: [policy] + operationId: createApprovalRule + summary: Propose a new approval rule (dual-control) + description: Creates a policy-change approval; the rule applies only after operator approval (plan S3). + x-required-scope: operator + parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ApprovalRuleCreate'} + responses: + '202': {$ref: '#/components/responses/PendingApproval'} + default: {$ref: '#/components/responses/Problem'} + + /policy/approval-rules/{id}: + parameters: [{$ref: '#/components/parameters/EntityId'}] + patch: + tags: [policy] + operationId: patchApprovalRule + summary: Propose a rule change (dual-control) + x-required-scope: operator + parameters: [{$ref: '#/components/parameters/IfMatch'}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ApprovalRuleCreate'} + responses: + '202': {$ref: '#/components/responses/PendingApproval'} + default: {$ref: '#/components/responses/Problem'} + + /policy/autonomy: + get: + tags: [policy] + operationId: getAutonomySettings + summary: Autonomy settings (kill-switch, never-auto-act list) + x-required-scope: viewer + responses: + '200': + description: Settings + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/AutonomySetting'}} + default: {$ref: '#/components/responses/Problem'} + patch: + tags: [policy] + operationId: patchAutonomySettings + summary: Propose autonomy changes (dual-control; kill-switch OFF is immediate) + description: | + Raising autonomy is dual-controlled (202 + approval). Lowering it — + setting `global.auto_act: "off"` or adding a never_auto_act key — + applies immediately (200): the kill-switch must never wait for an approval. + x-required-scope: operator + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: {type: string} + responses: + '200': + description: Applied (restriction) + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/AutonomySetting'}} + '202': {$ref: '#/components/responses/PendingApproval'} + default: {$ref: '#/components/responses/Problem'} + + # ─── Knowledge ──────────────────────────────────────────────────── + /knowledge/search: + get: + tags: [knowledge] + operationId: searchKnowledge + summary: Full-text search over knowledge entities (documents, runbooks) + x-required-scope: viewer + parameters: + - {name: q, in: query, required: true, schema: {type: string}} + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: Hits + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/KnowledgeHit'}} + default: {$ref: '#/components/responses/Problem'} + + /knowledge/{entity_id}: + parameters: + - {name: entity_id, in: path, required: true, schema: {type: string}} + get: + tags: [knowledge] + operationId: getEntityKnowledge + summary: All documents/runbooks linked to an entity + x-required-scope: viewer + responses: + '200': + description: Linked knowledge + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/KnowledgeHit'}} + default: {$ref: '#/components/responses/Problem'} + + # ─── Observability ──────────────────────────────────────────────── + /metrics: + get: + tags: [observability] + operationId: queryMetrics + summary: Query time-series metrics + x-required-scope: viewer + parameters: + - {name: entity_id, in: query, schema: {type: string}} + - {name: metric, in: query, schema: {type: array, items: {type: string}}, style: form, explode: true} + - {name: rollup, in: query, schema: {type: string, enum: [raw, 1h, 1d, auto], default: auto}} + - $ref: '#/components/parameters/FromTime' + - $ref: '#/components/parameters/ToTime' + responses: + '200': + description: Metric series + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/MetricSeries'}} + default: {$ref: '#/components/responses/Problem'} + + /trends/{entity_id}: + parameters: + - {name: entity_id, in: path, required: true, schema: {type: string}} + get: + tags: [observability] + operationId: getTrends + summary: Trend analysis for all metrics on an entity + x-required-scope: viewer + parameters: + - $ref: '#/components/parameters/FromTime' + - $ref: '#/components/parameters/ToTime' + responses: + '200': + description: Trends + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/Trend'}} + default: {$ref: '#/components/responses/Problem'} + + /audit: + get: + tags: [observability] + operationId: queryAudit + summary: Audit log + x-required-scope: operator + parameters: + - {name: actor_type, in: query, schema: {type: string}} + - {name: actor_id, in: query, schema: {type: string}} + - {name: entity_id, in: query, schema: {type: string}} + - {name: action, in: query, schema: {type: string}} + - {name: correlation_id, in: query, schema: {type: string}} + - $ref: '#/components/parameters/FromTime' + - $ref: '#/components/parameters/ToTime' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: Audit entries (ts DESC) + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/AuditEntry'}} + next_cursor: {type: [string, "null"]} + default: {$ref: '#/components/responses/Problem'} + + /events: + get: + tags: [observability] + operationId: queryEvents + summary: Historical events + x-required-scope: viewer + parameters: + - {name: type, in: query, schema: {type: string}} + - {name: entity_id, in: query, schema: {type: string}} + - {name: severity, in: query, schema: {type: string}} + - {name: correlation_id, in: query, schema: {type: string}} + - $ref: '#/components/parameters/FromTime' + - $ref: '#/components/parameters/ToTime' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: Events (ts DESC) + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/Event'}} + next_cursor: {type: [string, "null"]} + default: {$ref: '#/components/responses/Problem'} + + /events/stream: + get: + tags: [observability] + operationId: streamEvents + summary: Live event stream (SSE) + description: | + Server-Sent Events. Each event's `id` is the event row id (resume + with `Last-Event-ID`), `event` is the event type, `data` is the JSON + Event object. Heartbeat comments every 15s. Best-effort delivery — + bounded per-subscriber buffer, drop-oldest (plan P6); use GET /events + to backfill. + x-required-scope: viewer + parameters: + - {name: type, in: query, schema: {type: string}, description: Filter by event type prefix} + - {name: Last-Event-ID, in: header, schema: {type: string}} + responses: + '200': + description: SSE stream + content: + text/event-stream: + schema: {type: string} + default: {$ref: '#/components/responses/Problem'} + + /agent-activity: + get: + tags: [observability] + operationId: queryAgentActivity + summary: Agent behavior log + x-required-scope: viewer + parameters: + - {name: agent_id, in: query, schema: {type: string}} + - {name: activity_type, in: query, schema: {type: string}} + - {name: entity_id, in: query, schema: {type: string}} + - $ref: '#/components/parameters/FromTime' + - $ref: '#/components/parameters/ToTime' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: Activity entries + content: + application/json: + schema: + type: object + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/AgentActivity'}} + next_cursor: {type: [string, "null"]} + default: {$ref: '#/components/responses/Problem'} + + /health: + get: + tags: [observability] + operationId: getFleetHealth + summary: Fleet health summary with trend indicators + x-required-scope: viewer + responses: + '200': + description: Health summary + content: + application/json: + schema: {$ref: '#/components/schemas/HealthSummary'} + default: {$ref: '#/components/responses/Problem'} + + # ─── System ─────────────────────────────────────────────────────── + /export: + get: + tags: [system] + operationId: exportSeeds + summary: Regenerate seed YAMLs from current DB state (DR / version control) + x-required-scope: operator + responses: + '200': + description: Seed bundle + content: + application/json: + schema: + type: object + required: [ontology, inventory, policy] + properties: + ontology: {type: string, description: YAML document} + inventory: {type: string, description: YAML document} + policy: {type: string, description: YAML document} + default: {$ref: '#/components/responses/Problem'} + +components: + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: | + Operator/viewer: Authentik OIDC JWT (validated in-API — Caddy + forward-auth is defense-in-depth, not the source of truth). + Agent: static bearer token from Infisical (scope `agent`). + + parameters: + EntityId: + name: id + in: path + required: true + schema: {type: string} + description: UUID or slug (e.g. `host:hubris`) + Cursor: + name: cursor + in: query + schema: {type: string} + description: Opaque cursor from a previous response's next_cursor + Limit: + name: limit + in: query + schema: {type: integer, default: 50, maximum: 200, minimum: 1} + FromTime: + name: from + in: query + schema: {type: string, format: date-time} + ToTime: + name: to + in: query + schema: {type: string, format: date-time} + IfMatch: + name: If-Match + in: header + required: true + schema: {type: string} + description: ETag from a prior GET; 412 on version mismatch + IdempotencyKey: + name: Idempotency-Key + in: header + schema: {type: string, maxLength: 128} + description: Client-generated key; replays within 24h return the original response + + headers: + ETag: + schema: {type: string} + description: Resource version for If-Match + + responses: + Problem: + description: Error (RFC 9457) + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + PendingApproval: + description: Change staged; a meta-approval was created (dual-control) + content: + application/json: + schema: {$ref: '#/components/schemas/Approval'} + SignalUpdated: + description: Updated signal + content: + application/json: + schema: {$ref: '#/components/schemas/Signal'} + + schemas: + + Problem: + type: object + required: [title, status] + properties: + type: {type: string, format: uri, default: "about:blank"} + title: {type: string} + status: {type: integer} + detail: {type: string} + instance: {type: string} + errors: + type: array + description: Field-level validation errors (422) + items: + type: object + required: [field, reason] + properties: + field: {type: string} + reason: {type: string} + + Entity: + type: object + required: [id, slug, type, name, version, created_at, updated_at] + properties: + id: {type: string, format: uuid} + slug: {type: string, examples: ["host:hubris"]} + type: {type: string} + name: {type: string} + state: {type: [string, "null"]} + attributes: {type: object} + maintenance_until: {type: [string, "null"], format: date-time} + version: {type: integer} + created_at: {type: string, format: date-time} + updated_at: {type: string, format: date-time} + + EntityCreate: + type: object + required: [slug, type, name] + properties: + slug: {type: string} + type: {type: string, description: Must be a non-abstract entity type} + name: {type: string} + state: {type: string, description: Defaults to the lifecycle's default_state} + attributes: {type: object, description: Validated against the type's attribute_schema} + + EntityPatch: + type: object + description: At least one of the fields must be present. + properties: + name: {type: string} + state: {type: string, description: Target lifecycle state (transition validated)} + attributes: {type: object, description: Merged; validated against attribute_schema} + maintenance_until: {type: [string, "null"], format: date-time} + + Relationship: + type: object + required: [source, target, type, valid_from] + properties: + source: {type: string, description: Slug of source entity} + target: {type: string, description: Slug of target entity} + type: {type: string} + attributes: {type: [object, "null"]} + valid_from: {type: string, format: date-time} + valid_to: {type: [string, "null"], format: date-time} + + RelationshipCreate: + type: object + required: [source, target, type] + properties: + source: {type: string, description: UUID or slug} + target: {type: string, description: UUID or slug} + type: {type: string} + attributes: {type: object} + + GraphView: + type: object + required: [nodes, edges] + properties: + nodes: {type: array, items: {$ref: '#/components/schemas/Entity'}} + edges: {type: array, items: {$ref: '#/components/schemas/Relationship'}} + truncated: {type: boolean, description: True if node cap was hit} + + EntityType: + type: object + required: [name, domain, layer, is_abstract, status] + properties: + name: {type: string} + parent_type: {type: [string, "null"]} + is_abstract: {type: boolean} + domain: {type: string} + layer: {type: string, enum: [meta, infrastructure, governance, cognition]} + description: {type: string} + lifecycle_id: {type: [string, "null"]} + attribute_schema: {type: [object, "null"], description: JSON Schema} + schema_version: {type: integer} + status: {type: string, enum: [active, deprecated]} + version: {type: integer} + + EntityTypeCreate: + type: object + required: [name, domain, layer] + properties: + name: {type: string} + parent_type: {type: string} + is_abstract: {type: boolean, default: false} + domain: {type: string} + layer: {type: string, enum: [infrastructure, governance, cognition]} + description: {type: string} + lifecycle_id: {type: string} + attribute_schema: {type: object} + + EntityTypePatch: + type: object + properties: + description: {type: string} + attribute_schema: {type: object} + status: {type: string, enum: [active, deprecated]} + + RelationshipType: + type: object + required: [name, source_type, target_type, cardinality] + properties: + name: {type: string} + inverse: {type: [string, "null"]} + source_type: {type: string, description: May be abstract} + target_type: {type: string, description: May be abstract} + cardinality: {type: string, enum: [one-to-one, one-to-many, many-to-one, many-to-many]} + description: {type: string} + + LifecycleDef: + type: object + required: [id, states, default_state, transitions] + properties: + id: {type: string} + states: {type: array, items: {type: string}} + default_state: {type: string} + terminal_states: {type: array, items: {type: string}} + transitions: + type: object + description: '{from: {to: {requires: [named-check, ...]}}}' + + Signal: + type: object + required: [id, slug, kind, severity, state, occurrence_count, first_seen_at, last_seen_at] + properties: + id: {type: string, format: uuid} + slug: {type: string} + kind: {type: string, examples: [service-down, disk-threshold, drift, flapping]} + severity: {type: string, enum: [info, warning, critical]} + state: {type: string, enum: [raised, acknowledged, acting, muted, resolved, failed]} + target: {type: [string, "null"], description: Slug of the entity this concerns} + check_id: {type: [string, "null"]} + evidence: {type: [string, "null"]} + likely_cause: {type: [string, "null"]} + occurrence_count: {type: integer} + flap_count: {type: integer} + hold_down_until: {type: [string, "null"], format: date-time} + mute_until: {type: [string, "null"], format: date-time} + first_seen_at: {type: string, format: date-time} + last_seen_at: {type: string, format: date-time} + + Check: + type: object + required: [id, slug, kind, interval_s, timeout_s, enabled, version] + properties: + id: {type: string, format: uuid} + slug: {type: string} + kind: {type: string, enum: [http, tcp, disk, cert-expiry, drift, ssh-script]} + target: {type: [string, "null"], description: Entity slug (instance-scoped)} + target_type: {type: [string, "null"], description: Entity type (type-scoped)} + config: {type: object, description: Validated per-kind} + interval_s: {type: integer} + timeout_s: {type: integer} + zone: {type: [string, "null"]} + enabled: {type: boolean} + version: {type: integer} + + CheckCreate: + type: object + required: [slug, kind] + properties: + slug: {type: string} + kind: {type: string, enum: [http, tcp, disk, cert-expiry, drift, ssh-script]} + target: {type: string} + target_type: {type: string} + config: {type: object} + interval_s: {type: integer, default: 600} + timeout_s: {type: integer, default: 10} + zone: {type: string} + enabled: {type: boolean, default: true} + + CheckPatch: + type: object + properties: + config: {type: object} + interval_s: {type: integer} + timeout_s: {type: integer} + enabled: {type: boolean} + + Execution: + type: object + required: [id, slug, action, risk_class, status, correlation_id, created_at] + properties: + id: {type: string, format: uuid} + slug: {type: string} + target: {type: [string, "null"]} + action: {type: string} + risk_class: {type: string} + status: + type: string + enum: [proposed, approved, auto_approved, denied, expired, executing, + verifying, verified, failed, timed_out, cancelled, rolled_back, + rollback_failed] + classification_id: {type: [string, "null"]} + signal_id: {type: [string, "null"]} + approval_id: {type: [string, "null"]} + agent_id: {type: [string, "null"]} + skill_id: {type: [string, "null"]} + skill_version: {type: [integer, "null"]} + params: {type: object, description: Skill params (validated against params_schema)} + result: {type: [object, "null"]} + duration_ms: {type: [integer, "null"]} + verified: {type: boolean} + correlation_id: {type: string} + started_at: {type: [string, "null"], format: date-time} + completed_at: {type: [string, "null"], format: date-time} + created_at: {type: string, format: date-time} + + ExecutionRequest: + type: object + required: [target, action] + properties: + target: {type: string, description: Entity UUID or slug} + action: {type: string, examples: [restart, cache-clear]} + params: {type: object} + signal_id: {type: string, description: Signal that motivated this (optional)} + reason: {type: string} + + Classification: + type: object + required: [id, action, risk_class, route, reasoning, correlation_id, created_at] + properties: + id: {type: string, format: uuid} + signal_id: {type: [string, "null"]} + target: {type: [string, "null"]} + action: {type: string} + recommended_action: {type: [object, "null"]} + risk_class: {type: string} + route: {type: string, enum: [auto-act, escalate, hold]} + blast_radius: {type: array, items: {type: string}} + pattern_confidence: {type: [number, "null"]} + skill_id: {type: [string, "null"]} + autonomy_check: {type: string} + reasoning: {type: object} + correlation_id: {type: string} + created_at: {type: string, format: date-time} + + Approval: + type: object + required: [id, slug, action, risk_class, kind, status, expires_at, created_at] + properties: + id: {type: string, format: uuid} + slug: {type: string} + subject: {type: [string, "null"], description: Entity slug the approval concerns} + action: {type: string} + risk_class: {type: string} + kind: {type: string, enum: [execution, policy-change, pattern-activation]} + payload: {type: [object, "null"], description: e.g. proposed policy diff} + status: {type: string, enum: [pending, approved, denied, expired, revoked]} + expires_at: {type: string, format: date-time} + decided_at: {type: [string, "null"], format: date-time} + decided_by: {type: [string, "null"]} + created_at: {type: string, format: date-time} + + Pattern: + type: object + required: [id, slug, applies_type, action, pattern, confidence, evidence_count, status, version] + properties: + id: {type: string, format: uuid} + slug: {type: string} + applies_type: {type: string} + action: {type: string} + pattern: {type: string} + confidence: {type: number, description: "Wilson lower bound, capped by evidence_count/5"} + evidence_count: {type: integer} + success_count: {type: integer} + failure_count: {type: integer} + status: {type: string, enum: [hypothesized, validated, active, deprecated, invalidated]} + quarantined: {type: boolean} + version: {type: integer} + last_validated_at: {type: [string, "null"], format: date-time} + + Skill: + type: object + required: [id, slug, name, version, action, status, procedure] + properties: + id: {type: string, format: uuid} + slug: {type: string} + name: {type: string} + version: {type: integer} + action: {type: string} + applies_type: {type: [string, "null"]} + procedure: + type: object + description: Structured steps/verify/rollback/params (plan R3-9) + required: [steps, verify] + properties: + params_schema: {type: object} + steps: {type: array, items: {$ref: '#/components/schemas/SkillStep'}} + verify: {type: array, items: {$ref: '#/components/schemas/SkillStep'}} + rollback: {type: array, items: {$ref: '#/components/schemas/SkillStep'}} + expected_duration_s: {type: integer} + known_failure_modes: {type: array, items: {type: string}} + pattern_ids: {type: array, items: {type: string}} + status: {type: string, enum: [drafted, tested, active, refined, failed, deprecated]} + success_rate: {type: [number, "null"]} + changed_by: {type: [string, "null"]} + change_reason: {type: [string, "null"]} + last_used_at: {type: [string, "null"], format: date-time} + + SkillStep: + type: object + required: [runner, command] + properties: + name: {type: string} + runner: {type: string, enum: [ssh, http, internal]} + target: {type: string, description: Go template over params} + command: {type: string, description: Go template over params} + timeout_s: {type: integer, default: 60} + expect: + type: object + properties: + exit_code: {type: integer} + stdout_contains: {type: string} + retry: + type: object + properties: + attempts: {type: integer} + delay_s: {type: integer} + + RiskClass: + type: object + required: [name, approval_required, autonomy_allowed] + properties: + name: {type: string} + description: {type: string} + approval_required: {type: string, enum: [none, operator, operator_confirmed]} + autonomy_allowed: {type: boolean} + + ApprovalRule: + type: object + required: [id, action, risk_class, autonomy_level, version] + properties: + id: {type: string, format: uuid} + entity_type: {type: [string, "null"], description: May be abstract (inherits down)} + action: {type: string} + risk_class: {type: string} + autonomy_level: {type: string, enum: [auto, escalate, never]} + scope_entity: {type: [string, "null"], description: Entity slug for per-entity overrides} + version: {type: integer} + + ApprovalRuleCreate: + type: object + required: [action, risk_class, autonomy_level] + properties: + entity_type: {type: string} + action: {type: string} + risk_class: {type: string} + autonomy_level: {type: string, enum: [auto, escalate, never]} + scope_entity: {type: string} + + AutonomySetting: + type: object + required: [key, value, version] + properties: + key: {type: string, examples: [global.auto_act]} + value: {type: string} + version: {type: integer} + updated_at: {type: string, format: date-time} + + KnowledgeHit: + type: object + required: [id, slug, type, title] + properties: + id: {type: string, format: uuid} + slug: {type: string} + type: {type: string, enum: [document, runbook, investigation]} + title: {type: string} + source_path: {type: [string, "null"]} + snippet: {type: [string, "null"], description: Highlighted match context} + linked_entities: {type: array, items: {type: string}} + rank: {type: [number, "null"]} + + MetricSeries: + type: object + required: [entity_id, metric, rollup, samples] + properties: + entity_id: {type: string} + metric: {type: string} + rollup: {type: string, enum: [raw, 1h, 1d]} + samples: + type: array + items: + type: object + required: [ts] + properties: + ts: {type: string, format: date-time} + value: {type: [number, "null"], description: Raw sample value} + avg: {type: [number, "null"]} + min: {type: [number, "null"]} + max: {type: [number, "null"]} + count: {type: [integer, "null"]} + trend: {$ref: '#/components/schemas/Trend'} + + Trend: + type: object + required: [metric, direction] + properties: + metric: {type: string} + direction: {type: string, enum: [improving, degrading, stable, unknown]} + slope: {type: [number, "null"], description: Linear fit per day} + anomaly: {type: boolean} + forecast: {type: [number, "null"], description: "Simple linear projection, 7d out"} + + AuditEntry: + type: object + required: [id, ts, actor_type, action] + properties: + id: {type: integer} + ts: {type: string, format: date-time} + actor_type: {type: string, enum: [agent, operator, system, scheduler]} + actor_id: {type: [string, "null"]} + action: {type: string} + entity_id: {type: [string, "null"]} + method: {type: [string, "null"]} + path: {type: [string, "null"]} + status_code: {type: [integer, "null"]} + detail: {type: object} + source_ip: {type: [string, "null"]} + correlation_id: {type: [string, "null"]} + + Event: + type: object + required: [id, ts, type, severity, source] + properties: + id: {type: integer} + ts: {type: string, format: date-time} + type: {type: string, examples: [signal.raised, execution.completed]} + entity_id: {type: [string, "null"]} + severity: {type: string, enum: [info, warning, critical]} + source: {type: string} + data: {type: object} + correlation_id: {type: [string, "null"]} + + AgentActivity: + type: object + required: [id, ts, agent_id, activity_type] + properties: + id: {type: integer} + ts: {type: string, format: date-time} + agent_id: {type: string} + session_id: {type: [string, "null"]} + activity_type: {type: string, enum: [tool_call, reasoning, decision, mcp_query, escalation]} + tool_name: {type: [string, "null"]} + entity_id: {type: [string, "null"]} + input_summary: {type: [string, "null"]} + output_summary: {type: [string, "null"]} + duration_ms: {type: [integer, "null"]} + token_count: {type: [integer, "null"]} + success: {type: [boolean, "null"]} + correlation_id: {type: [string, "null"]} + + HealthSummary: + type: object + required: [summary, entities] + properties: + summary: + type: object + required: [healthy, degraded, down, unknown] + properties: + healthy: {type: integer} + degraded: {type: integer} + down: {type: integer} + unknown: {type: integer} + entities: + type: array + items: + type: object + required: [slug, type, health] + properties: + slug: {type: string} + type: {type: string} + health: {type: string, enum: [healthy, degraded, down, unknown]} + trend: {type: [string, "null"], enum: [improving, degrading, stable, unknown, null]} + last_check_at: {type: [string, "null"], format: date-time} diff --git a/api/redocly.yaml b/api/redocly.yaml new file mode 100644 index 0000000..796f91c --- /dev/null +++ b/api/redocly.yaml @@ -0,0 +1,7 @@ +# Redocly lint config for api/openapi.yaml (CI runs: redocly lint api/openapi.yaml) +extends: + - recommended +rules: + # Every operation declares `default` → RFC 9457 problem+json instead of + # enumerating each 4XX (plan R3-3); oapi-codegen handles `default` fine. + operation-4xx-response: off diff --git a/docs/adr/0001-go-single-binary.md b/docs/adr/0001-go-single-binary.md new file mode 100644 index 0000000..a7d6111 --- /dev/null +++ b/docs/adr/0001-go-single-binary.md @@ -0,0 +1,23 @@ +# ADR 0001 — Go with single-binary role packaging + +Status: accepted (2026-07-07) · Plan: rev 3, R3-4 + +## Context +The OS has three long-running roles (api, scheduler+actuator+learning, +notifier) plus one-shot jobs (migrate, seed, export). Rev 2 planned three +binaries with three Dockerfiles. + +## Decision +One Go binary `oikos` with role subcommands (`oikos api | scheduler | +notifier | all | migrate | seed | export`), one multi-stage Dockerfile, one +image tagged `oikos:`. Compose runs the image N times with +different commands (Loki/Temporal pattern). Go over Python for static +typing, small static binaries (CGO_ENABLED=0, distroless), and goroutines +for concurrent probes. + +## Consequences +- One build, guaranteed version consistency across roles, trivial local dev + (`oikos all`), simpler rollback (retag one image). +- Full rewrite of ~4,400 Python lines (logic carries over per plan reuse map). +- All roles share a dependency set; image is slightly larger than per-role + minimal images — accepted. diff --git a/docs/adr/0002-postgres-timescale-only-datastore.md b/docs/adr/0002-postgres-timescale-only-datastore.md new file mode 100644 index 0000000..8857e6f --- /dev/null +++ b/docs/adr/0002-postgres-timescale-only-datastore.md @@ -0,0 +1,25 @@ +# ADR 0002 — PostgreSQL + TimescaleDB as the only datastore + +Status: accepted (2026-07-07) · Plan: rev 3 + +## Context +The OS needs a graph (entities/relationships), operational tables +(signals/executions/approvals), a learning corpus, time-series metrics, +audit and event logs. Alternatives: dedicated graph DB (Neo4j), dedicated +TSDB (Prometheus/VictoriaMetrics), or one Postgres. + +## Decision +One PostgreSQL 16 instance with the TimescaleDB extension +(timescale/timescaledb:2-pg16). Graph traversal via recursive CTEs +(cycle-safe blast_radius); time-series via hypertables + continuous +aggregates + retention policies; events via table + LISTEN/NOTIFY. + +## Consequences +- One backup/restore/DR story, one connection pool, transactional + consistency between graph and operational writes (e.g. event emission in + the same transaction as state change). +- Postgres is the accepted SPOF — mitigated by daily pg_dump + WAL PITR + + off-host copies + monthly restore drills; streaming replication is the + future path if needed. +- Homelab graph scale (hundreds of nodes) is far below where a dedicated + graph DB pays for itself. diff --git a/docs/adr/0003-db-native-ontology-yaml-seeds.md b/docs/adr/0003-db-native-ontology-yaml-seeds.md new file mode 100644 index 0000000..a397d8d --- /dev/null +++ b/docs/adr/0003-db-native-ontology-yaml-seeds.md @@ -0,0 +1,23 @@ +# ADR 0003 — DB-native ontology with YAML seed manifests + +Status: accepted (2026-07-07) · Plan: rev 3, R3-1 + +## Context +Rev 1 kept inventory/ontology/policy as YAML files parsed at runtime. +Agents need graph queries (blast radius), transactional mutations with +audit, and a future UI needs to edit the model without file round-trips. + +## Decision +The DB is the runtime source of truth. entity_types form an is-a hierarchy +(parent_type, is_abstract); relationship endpoint constraints may name +abstract types and validation walks the hierarchy. YAML files under seeds/ +bootstrap the DB (idempotent, content-hashed via seed_versions) and serve +DR; `GET /api/v1/export` regenerates them for version control (round-trip +byte-stable, tested in CI). + +## Consequences +- Ontology changes are API calls (policy-gated), not redeploys. +- Seeds can drift from DB between exports — export is part of the routine + (commit after meaningful model edits). +- Abstract types let policy rules and relationships bind once at the right + altitude (e.g. `compute-entity provides service`). diff --git a/docs/adr/0004-openapi-first.md b/docs/adr/0004-openapi-first.md new file mode 100644 index 0000000..815d747 --- /dev/null +++ b/docs/adr/0004-openapi-first.md @@ -0,0 +1,21 @@ +# ADR 0004 — Contract-first OpenAPI API + +Status: accepted (2026-07-07) · Plan: rev 3, R3-2/R3-3 + +## Context +Future UIs, a CLI client, and an MCP surface must stay in sync with the +API. Code-first (Gin + generated docs) drifts. + +## Decision +api/openapi.yaml (OpenAPI 3.1) is the source of truth. Server stubs via +oapi-codegen (strict server, chi router); clients generated for Go (CLI) +and TypeScript (future UI). Conventions: RFC 9457 problem+json errors, +{items, next_cursor} envelopes, cursor pagination, Idempotency-Key on +unsafe POSTs, ETag/If-Match optimistic concurrency, scopes +(operator/viewer/agent) annotated per operation. CI fails on spec/handler +drift. MCP tools wrap the same service layer. + +## Consequences +- UI development needs only the running API (spec served at /openapi.yaml). +- Handler changes require spec changes first — deliberate friction. +- Breaking changes ship as /api/v2 side by side; v1 is additive-only. diff --git a/docs/adr/0005-uuidv7-plus-slug-identity.md b/docs/adr/0005-uuidv7-plus-slug-identity.md new file mode 100644 index 0000000..9ffff3b --- /dev/null +++ b/docs/adr/0005-uuidv7-plus-slug-identity.md @@ -0,0 +1,18 @@ +# ADR 0005 — UUIDv7 + slug entity identity + +Status: accepted (2026-07-07) · Plan: rev 3, R3-5 (resolves audit D1) + +## Context +Rev 2 used TEXT primary keys ('host:hubris') — renames break FKs, and +date-string signal IDs are race-prone. + +## Decision +Primary keys are UUIDv7 (time-ordered, generated in Go). Every entity also +carries a unique human slug ('host:hubris'); (type, name) is unique too. +The API accepts UUID or slug everywhere; slugs may change (rename), UUIDs +never do. + +## Consequences +- Renames are metadata updates; history and edges survive. +- UUIDv7's time-ordering keeps B-tree inserts append-mostly. +- Seeds and exports use slugs (human-diffable); ingest resolves to UUIDs. diff --git a/docs/adr/0006-learning-proposal-only.md b/docs/adr/0006-learning-proposal-only.md new file mode 100644 index 0000000..c9ca887 --- /dev/null +++ b/docs/adr/0006-learning-proposal-only.md @@ -0,0 +1,23 @@ +# ADR 0006 — Learning is proposal-only (no self-authorization) + +Status: accepted (2026-07-07) · Plan: rev 3 (resolves audit S3/S4/SA2) + +## Context +The learning loop (feedback → patterns → skills) informs the classifier +that decides auto-act vs escalate. If learning could expand its own +autonomy, poisoned feedback (flapping services, biased probes) could +unlock destructive auto-act. + +## Decision +The learning engine cannot write to governance (policy/autonomy) tables — +enforced structurally: its DB role has no grants on them. Pattern +activation (validated → active) and any autonomy expansion require operator +approval. Confidence is the Wilson lower bound capped by evidence_count/5; +anomalous feedback bursts quarantine the pattern; no skill ever +auto-promotes an action into destructive autonomy (hard-coded). Lowering +autonomy (kill-switch) is always immediate, never gated. + +## Consequences +- Cold start is slow by design — the agent escalates until trust is earned. +- The operator is the only path to more autonomy; the audit trail shows + every grant. diff --git a/docs/adr/0007-threat-model.md b/docs/adr/0007-threat-model.md new file mode 100644 index 0000000..87501d8 --- /dev/null +++ b/docs/adr/0007-threat-model.md @@ -0,0 +1,27 @@ +# ADR 0007 — Threat model and trust zones + +Status: accepted (2026-07-07) · Plan: rev 3, Security model section + +## Context +The control plane can restart services and (eventually) mutate config +fleet-wide. Compromise of any one container must not equal compromise of +the fleet. + +## Decision +Trust zones as Docker networks: net-front (Caddy→api only), net-data +(Postgres), net-ops (SSH egress, actuator only). Hermes holds no SSH keys; +the actuator uses a restricted key (command=/from= in authorized_keys) +until the /executions gateway fully brokers actions. Caddy is an explicit +trust root but the API independently validates OIDC JWTs — network origin +is defense-in-depth, never the auth (this enables the LAN break-glass API +binding; the Hermes gateway remains mesh-only). Policy changes are +dual-controlled with before/after hash auditing and a startup +hash-vs-known-good check. Approval tokens are single-use HMAC, hashed at +rest, TTL-bound. + +## Consequences +- Documented residual risks: plaintext LAN break-glass hop (emergency use), + Postgres as shared dependency of all roles, macOS host itself unmanaged + by the OS. +- Rotation cadences: actuator SSH key 6mo, machine tokens 90d, webhook + HMAC 1y — scheduler raises expiry signals 2 weeks ahead. diff --git a/docs/adr/0008-forward-only-migrations.md b/docs/adr/0008-forward-only-migrations.md new file mode 100644 index 0000000..5974f03 --- /dev/null +++ b/docs/adr/0008-forward-only-migrations.md @@ -0,0 +1,20 @@ +# ADR 0008 — Forward-only migrations + +Status: accepted (2026-07-07) · Plan: rev 3 (resolves audit D5/O1) + +## Context +Down-migrations are rarely tested and lie about reversibility once data +has flowed. Rollback needs a strategy that works with real data. + +## Decision +golang-migrate, embedded (//go:embed), up-only. Migrations run in a +one-shot init container with a DDL-only DB user before app roles start. +Within one deploy window migrations are additive-only (new columns +nullable, new tables optional) so previous-SHA images tolerate the new +schema. Rollback = redeploy previous image tag; if the migration itself is +the problem, pg_restore the automatic pre-deploy dump. Mistakes roll +forward via compensating migrations. + +## Consequences +- No down.sql to write or test; the pre-deploy dump is the real safety net. +- Destructive schema changes (drop/rename) take two deploys by design. diff --git a/docs/adr/0009-sse-over-websocket.md b/docs/adr/0009-sse-over-websocket.md new file mode 100644 index 0000000..390b5cd --- /dev/null +++ b/docs/adr/0009-sse-over-websocket.md @@ -0,0 +1,19 @@ +# ADR 0009 — SSE over WebSocket for the event stream + +Status: accepted (2026-07-07) · Plan: rev 3, R3-14 + +## Context +Live updates (signals, executions, approvals) push server→client only. +Rev 2 specified WebSocket. + +## Decision +Server-Sent Events at GET /api/v1/events/stream: plain HTTP (proxies +through Caddy without upgrade handling), native browser EventSource with +auto-reconnect, Last-Event-ID resume backed by the events table. Bounded +per-subscriber buffers with drop-oldest; heartbeat comments every 15s. +Delivery is best-effort — GET /events backfills. Transactional emission + +post-commit LISTEN/NOTIFY feed the stream. + +## Consequences +- No bidirectional channel; if one is ever needed (interactive terminals), + add WebSocket alongside — this ADR covers the event feed only. diff --git a/docs/adr/0010-infisical-with-sops-fallback.md b/docs/adr/0010-infisical-with-sops-fallback.md new file mode 100644 index 0000000..584ff65 --- /dev/null +++ b/docs/adr/0010-infisical-with-sops-fallback.md @@ -0,0 +1,20 @@ +# ADR 0010 — Infisical secrets with SOPS DR fallback + +Status: accepted (2026-07-07) · Plan: rev 3, Phase 5 (resolves audit S9) + +## Context +SOPS+age is file-based: no runtime API, no machine identities, no +rotation tracking, and every consumer needs the age key. + +## Decision +Infisical in the Docker stack; services fetch via machine identities; +secrets never in env files or plain config (config hierarchy: defaults → +file → env → Infisical, secrets only). Bootstrap root of trust: Infisical +master key in the mac-mini Keychain, backed up offline. One age key is +retained and all secrets are exported to a SOPS-encrypted fallback file +until an Infisical restore drill has passed; the fallback is refreshed on +rotation. + +## Consequences +- Chicken-and-egg is explicit: the Keychain + offline copy are the root. +- SOPS retirement is gated on a passed restore drill, not on the calendar. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..51075fc --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,18 @@ +# Architecture Decision Records + +MADR-style records for Oikos. One decision per file, numbered, never edited +after acceptance — superseding decisions get a new ADR that links back. +Statuses: proposed | accepted | superseded-by-NNNN. + +| ADR | Title | +|---|---| +| [0001](0001-go-single-binary.md) | Go with single-binary role packaging | +| [0002](0002-postgres-timescale-only-datastore.md) | PostgreSQL + TimescaleDB as the only datastore | +| [0003](0003-db-native-ontology-yaml-seeds.md) | DB-native ontology with YAML seed manifests | +| [0004](0004-openapi-first.md) | Contract-first OpenAPI API | +| [0005](0005-uuidv7-plus-slug-identity.md) | UUIDv7 + slug entity identity | +| [0006](0006-learning-proposal-only.md) | Learning is proposal-only (no self-authorization) | +| [0007](0007-threat-model.md) | Threat model and trust zones | +| [0008](0008-forward-only-migrations.md) | Forward-only migrations | +| [0009](0009-sse-over-websocket.md) | SSE over WebSocket for the event stream | +| [0010](0010-infisical-with-sops-fallback.md) | Infisical secrets with SOPS DR fallback | diff --git a/plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md b/plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md index 1f03463..78687c8 100644 --- a/plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md +++ b/plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md @@ -711,7 +711,8 @@ CREATE TABLE entity_types ( is_abstract BOOLEAN NOT NULL DEFAULT false, -- abstract types can't be instantiated domain TEXT NOT NULL, -- 'physical','compute','network','storage', -- 'software','identity','policy','cognition' - layer TEXT NOT NULL CHECK (layer IN ('infrastructure','governance','cognition')), + layer TEXT NOT NULL CHECK (layer IN ('meta','infrastructure','governance','cognition')), + -- 'meta' is reserved for the abstract root type 'entity' description TEXT, lifecycle_id TEXT REFERENCES lifecycle_defs(id), attribute_schema JSONB, -- JSON Schema for entities.attributes (D2) @@ -727,8 +728,8 @@ CREATE TABLE relationship_types ( inverse TEXT, source_type TEXT NOT NULL REFERENCES entity_types(name), -- MAY be abstract; target_type TEXT NOT NULL REFERENCES entity_types(name), -- validation walks hierarchy - cardinality TEXT NOT NULL CHECK (cardinality IN - ('one-to-one','one-to-many','many-to-many')), + cardinality TEXT NOT NULL CHECK (cardinality IN -- source→target multiplicity + ('one-to-one','one-to-many','many-to-one','many-to-many')), description TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); diff --git a/scripts/validate-seeds.py b/scripts/validate-seeds.py new file mode 100644 index 0000000..402f3fd --- /dev/null +++ b/scripts/validate-seeds.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Validate seeds/*.yaml against the Oikos meta-schema (Phase 0 gate). + +Checks the same invariants the Go ingest (internal/ontology) will enforce: + +ontology.yaml + - every entity type's parent exists; hierarchy is acyclic + - layer/cardinality values are legal + - lifecycle references exist; default/terminal/transition states are declared + - relationship endpoint types exist (may be abstract) + - inverse names don't collide with forward names + +inventory.yaml + - slugs unique and well-formed (:) + - entity types exist and are NOT abstract + - states are legal for the type's lifecycle (walking up the hierarchy for + the lifecycle definition is not needed — lifecycle binds per type) + - relationship endpoints exist; edge type exists; endpoint entity types + are the declared source/target types or descendants (hierarchy walk) + - cardinality: one-to-one / one-to-many / many-to-one uniqueness holds + within the seed + - attributes validate against attribute_schema (if jsonschema installed) + +policy.yaml + - approval_rules reference existing risk classes / entity types / entities + - autonomy values sane + +Exit 0 = clean (warnings allowed), 1 = errors. +""" +import sys +from pathlib import Path + +try: + import yaml +except ImportError: + print("ERROR: PyYAML required (pip install pyyaml)") + sys.exit(1) + +try: + import jsonschema + HAVE_JSONSCHEMA = True +except ImportError: + HAVE_JSONSCHEMA = False + +SEEDS = Path(__file__).resolve().parent.parent / "seeds" +LAYERS = {"meta", "infrastructure", "governance", "cognition"} +CARDINALITIES = {"one-to-one", "one-to-many", "many-to-one", "many-to-many"} + +errors: list[str] = [] +warnings: list[str] = [] + + +def err(msg: str) -> None: + errors.append(msg) + + +def warn(msg: str) -> None: + warnings.append(msg) + + +def load(name: str) -> dict: + path = SEEDS / name + if not path.exists(): + err(f"{name}: file missing") + return {} + with open(path) as f: + try: + return yaml.safe_load(f) or {} + except yaml.YAMLError as e: + err(f"{name}: YAML parse error: {e}") + return {} + + +# ─── ontology.yaml ──────────────────────────────────────────────────── +onto = load("ontology.yaml") +etypes: dict = onto.get("entity_types", {}) +rtypes: dict = onto.get("relationship_types", {}) +lifecycles: dict = onto.get("lifecycles", {}) + +for name, lc in lifecycles.items(): + states = lc.get("states", []) + if not states: + err(f"lifecycle {name}: no states") + continue + if lc.get("default_state") not in states: + err(f"lifecycle {name}: default_state {lc.get('default_state')!r} not in states") + for t in lc.get("terminal_states", []): + if t not in states: + err(f"lifecycle {name}: terminal state {t!r} not in states") + for frm, tos in (lc.get("transitions") or {}).items(): + if frm not in states: + err(f"lifecycle {name}: transition source {frm!r} not in states") + for to, spec in (tos or {}).items(): + if to not in states: + err(f"lifecycle {name}: transition target {to!r} not in states") + if spec is not None and not isinstance(spec.get("requires", []), list): + err(f"lifecycle {name}: {frm}->{to} requires must be a list") + +for name, et in etypes.items(): + parent = et.get("parent") + if parent is not None and parent not in etypes: + err(f"entity type {name}: parent {parent!r} not defined") + layer = et.get("layer") + if layer not in LAYERS: + err(f"entity type {name}: layer {layer!r} invalid (want {sorted(LAYERS)})") + lc = et.get("lifecycle") + if lc is not None and lc not in lifecycles: + err(f"entity type {name}: lifecycle {lc!r} not defined") + schema = et.get("attributes") + if schema is not None and HAVE_JSONSCHEMA: + try: + jsonschema.Draft202012Validator.check_schema(schema) + except jsonschema.SchemaError as e: + err(f"entity type {name}: attribute_schema is not valid JSON Schema: {e.message}") + +# hierarchy acyclicity + ancestor helper +def ancestors(t: str) -> list[str]: + chain, seen = [], set() + cur = t + while cur is not None: + if cur in seen: + err(f"entity type hierarchy cycle at {cur!r}") + break + seen.add(cur) + chain.append(cur) + cur = etypes.get(cur, {}).get("parent") + return chain + +for name in etypes: + ancestors(name) + +def is_a(t: str, target: str) -> bool: + return target in ancestors(t) + +fwd_names = set(rtypes) +for name, rt in rtypes.items(): + for endpoint in ("source", "target"): + v = rt.get(endpoint) + if v not in etypes: + err(f"relationship type {name}: {endpoint} {v!r} not a defined entity type") + if rt.get("cardinality") not in CARDINALITIES: + err(f"relationship type {name}: cardinality {rt.get('cardinality')!r} invalid") + inv = rt.get("inverse") + if inv and inv in fwd_names: + err(f"relationship type {name}: inverse {inv!r} collides with a forward name") + +# ─── inventory.yaml ─────────────────────────────────────────────────── +inv = load("inventory.yaml") +entities: list = inv.get("entities", []) +relationships: list = inv.get("relationships", []) + +slugs: dict = {} +for e in entities: + slug = e.get("slug") + if not slug or ":" not in slug: + err(f"entity {e}: slug missing or not ':'") + continue + if slug in slugs: + err(f"duplicate slug {slug!r}") + slugs[slug] = e + t = e.get("type") + if t not in etypes: + err(f"{slug}: type {t!r} not in ontology") + continue + if etypes[t].get("abstract"): + err(f"{slug}: type {t!r} is abstract — cannot be instantiated") + if not e.get("name"): + err(f"{slug}: name missing") + state = e.get("state") + lc_name = etypes[t].get("lifecycle") + if state is not None: + if lc_name is None: + err(f"{slug}: has state {state!r} but type {t!r} has no lifecycle") + elif state not in lifecycles.get(lc_name, {}).get("states", []): + err(f"{slug}: state {state!r} not in lifecycle {lc_name!r}") + schema = etypes[t].get("attributes") + if schema and HAVE_JSONSCHEMA and e.get("attributes"): + v = jsonschema.Draft202012Validator(schema) + for ve in v.iter_errors(e["attributes"]): + warn(f"{slug}: attributes: {ve.message}") + +# uniqueness of (type, name) +seen_tn = set() +for e in entities: + tn = (e.get("type"), e.get("name")) + if tn in seen_tn: + err(f"duplicate (type, name): {tn}") + seen_tn.add(tn) + +edge_keys = set() +by_card_src: dict = {} +by_card_tgt: dict = {} +for r in relationships: + src, tgt, rt_name = r.get("source"), r.get("target"), r.get("type") + ctx = f"edge {src} -{rt_name}-> {tgt}" + if rt_name not in rtypes: + err(f"{ctx}: relationship type not in ontology") + continue + ok = True + for label, slug in (("source", src), ("target", tgt)): + if slug not in slugs: + err(f"{ctx}: {label} entity {slug!r} not in inventory") + ok = False + if not ok: + continue + key = (src, tgt, rt_name) + if key in edge_keys: + err(f"{ctx}: duplicate edge") + edge_keys.add(key) + rt = rtypes[rt_name] + for label, slug, want in (("source", src, rt["source"]), ("target", tgt, rt["target"])): + actual = slugs[slug]["type"] + if not is_a(actual, want): + err(f"{ctx}: {label} type {actual!r} is not a {want!r} (or descendant)") + # cardinality bookkeeping (source→target multiplicity) + card = rt.get("cardinality") + if card in ("one-to-one", "many-to-one"): + # each source has at most one outgoing edge of this type + k = (rt_name, src) + if k in by_card_src: + err(f"{ctx}: cardinality {card} — source {src!r} already has a {rt_name!r} edge") + by_card_src[k] = tgt + if card in ("one-to-one", "one-to-many"): + # each target has at most one incoming edge of this type + k = (rt_name, tgt) + if k in by_card_tgt: + err(f"{ctx}: cardinality {card} — target {tgt!r} already has a {rt_name!r} edge") + by_card_tgt[k] = src + +# ─── policy.yaml ────────────────────────────────────────────────────── +pol = load("policy.yaml") +rclasses: dict = pol.get("risk_classes", {}) +for name, rc in rclasses.items(): + if rc.get("approval_required") not in ("none", "operator", "operator_confirmed"): + err(f"risk class {name}: approval_required invalid") + +rules = pol.get("approval_rules", []) +seen_rules = set() +for rule in rules: + ctx = f"rule ({rule.get('entity_type')}, {rule.get('action')}, {rule.get('scope_entity')})" + if rule.get("risk_class") not in rclasses: + err(f"{ctx}: risk_class {rule.get('risk_class')!r} not defined") + if rule.get("autonomy_level") not in ("auto", "escalate", "never"): + err(f"{ctx}: autonomy_level invalid") + et = rule.get("entity_type") + if et is not None and et not in etypes: + err(f"{ctx}: entity_type {et!r} not in ontology") + scope = rule.get("scope_entity") + if scope is not None and scope not in slugs: + err(f"{ctx}: scope_entity {scope!r} not in inventory") + key = (et, rule.get("action"), scope) + if key in seen_rules: + err(f"{ctx}: duplicate rule") + seen_rules.add(key) + # rule sanity: autonomy 'auto' requires the class to allow autonomy + rc = rclasses.get(rule.get("risk_class"), {}) + if rule.get("autonomy_level") == "auto" and not rc.get("autonomy_allowed"): + err(f"{ctx}: autonomy_level=auto but risk class forbids autonomy") + +auto = pol.get("autonomy_settings", {}) +ga = auto.get("global.auto_act") +if ga not in ("off", "reversible_low"): + err(f"autonomy_settings: global.auto_act {ga!r} invalid (off | reversible_low)") +for k in auto: + if k.startswith("never_auto_act."): + slug = k.split(".", 1)[1] + if slug not in slugs: + warn(f"autonomy_settings: {k} references unknown entity {slug!r}") + +# ─── report ─────────────────────────────────────────────────────────── +print(f"entity types: {len(etypes)} ({sum(1 for e in etypes.values() if e.get('abstract'))} abstract)") +print(f"relationship types: {len(rtypes)}") +print(f"lifecycles: {len(lifecycles)}") +print(f"entities: {len(entities)}") +print(f"relationships: {len(relationships)}") +print(f"risk classes: {len(rclasses)}, rules: {len(rules)}") +if not HAVE_JSONSCHEMA: + warn("jsonschema not installed — attribute schema validation skipped") +for w in warnings: + print(f"WARN {w}") +for e in errors: + print(f"ERROR {e}") +print(f"\n{'FAIL' if errors else 'OK'} — {len(errors)} error(s), {len(warnings)} warning(s)") +sys.exit(1 if errors else 0) diff --git a/seeds/inventory.yaml b/seeds/inventory.yaml new file mode 100644 index 0000000..d279c26 --- /dev/null +++ b/seeds/inventory.yaml @@ -0,0 +1,534 @@ +# Oikos inventory seed — entity instances + relationships. +# +# Translated from the legacy /inventory.yaml (2026-07-07). Bootstraps the +# entities/relationships tables (migration 002); after ingest the DB is +# authoritative and this file is regenerated by `GET /api/v1/export`. +# +# Slug conventions: : — +# host: (proxmox-host, standalone-server) · ws: (workstation) · lxc: · vm: +# service: · ingress: · repo: (config-repo) · pool: · volume: · mesh: · lan: +# zone: (dns-zone) · idp: · person: · agent: · cluster: · backup: +# +# `state:` omitted = the type's lifecycle default (active). +# Mount details (mount_point) are attributes on `mounts` edges. +# Known thin spots are marked # THIN: backfill later. + +version: 1 + +entities: + + # ─── Sites, networks ─────────────────────────────────────────────── + - {slug: "site:home", type: site, name: home} + - {slug: "site:ionos-dc", type: site, name: ionos-dc, + attributes: {address: IONOS datacenter (VPS)}} + - slug: "lan:lab" + type: lan + name: lab + attributes: {subnet: 192.168.8.0/24} + - slug: "lan:household" + type: lan + name: household + attributes: {subnet: 192.168.178.0/24} # Fritz LAN; static route to lab subnet + - slug: "mesh:netbird" + type: mesh + name: netbird + attributes: + provider: netbird + subnet: 100.122.0.0/16 + domain: netbird.selfhosted + - slug: "mesh:tailscale" + type: mesh + name: tailscale + state: deprecated # migration to netbird in progress (infrastructure/mesh.md) + attributes: {provider: tailscale} + - slug: "zone:hubris.network" + type: dns-zone + name: hubris.network + attributes: {zone: hubris.network, authority: "Technitium (LXC 107), split-horizon"} + - slug: "zone:netbird.selfhosted" + type: dns-zone + name: netbird.selfhosted + attributes: {zone: netbird.selfhosted, authority: netbird-mgmt} + + # ─── Machines ────────────────────────────────────────────────────── + - slug: "cluster:homelab" + type: cluster + name: Homelab + attributes: {quorum: "2-node, no QDevice tiebreaker yet"} + - slug: "host:hubris" + type: proxmox-host + name: hubris + attributes: + os: linux + lan_ip: 192.168.8.77 + mesh: {netbird: {ip: 100.122.38.109, fqdn: proxmox-server.netbird.selfhosted}} + ssh: {port: 22, netbird_port: 22022, user: root} + age_pubkey: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6 + - slug: "host:strong" + type: proxmox-host + name: strong + attributes: + os: linux + lan_ip: 192.168.178.181 + ssh: {user: root} + age_pubkey: age1rtwvdct6avjkr3cyxv3vue3vqx4d524fjfr3vk7xrnvyrylnry5sm54sn4 + note: >- + PVE 9.2.3 since 2026-07-01 (formerly workstation ludo-mini). Joined + Homelab cluster same day. Not yet netbird-enrolled — reachable via + household LAN / Fritz static route only. + - slug: "host:netbird-vps" + type: standalone-server + name: netbird-vps + attributes: + os: linux + provider: ionos + control_level: partial # managed via ssh from hubris; not a homelab client + public_ipv4: 82.165.190.79 + mesh: {netbird: {ip: 100.122.165.149, fqdn: netbird-ionos.netbird.selfhosted}} + ssh: {user: root} + note: netbird mgmt+signal+relay+dashboard + coturn; sshd locked to hubris pubkey + - slug: "ws:mac-mini" + type: workstation + name: mac-mini + attributes: + os: macos + user: dtoro + lan_ip: 192.168.178.182 + mesh: {netbird: {fqdn: mac-mini-234-17.netbird.selfhosted}} + age_pubkey: age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs + note: only macOS in fleet; future Oikos OS Docker host + - slug: "ws:republic-laptop" + type: workstation + name: republic-laptop + attributes: + os: linux + user: dtoro + mesh: {netbird: {fqdn: republic-laptop.netbird.selfhosted}} + + # ─── LXCs ────────────────────────────────────────────────────────── + - {slug: "lxc:jellyfin", type: lxc, name: jellyfin, + attributes: {pve_id: 101, role: media-server, lan_ip: 192.168.8.246, + public_host: media.hubris.network, + note: "VAAPI transcode via Radeon 680M passthrough; migrated hubris→strong 2026-07-05"}} + - {slug: "lxc:nfs-export", type: lxc, name: nfs-export, + attributes: {pve_id: 102, role: storage-export, lan_ip: 192.168.8.200}} + - {slug: "lxc:paperless", type: lxc, name: paperless, + attributes: {pve_id: 103, role: document-archive, lan_ip: 192.168.8.130, + public_host: paperless.hubris.network}} + - {slug: "lxc:gitea", type: lxc, name: gitea, + attributes: {pve_id: 104, role: git-server, lan_ip: 192.168.8.121, + public_host: git.hubris.network, + note: "bare repos at /mnt/library/repos/dtoro/*.git"}} + - {slug: "lxc:apps", type: lxc, name: apps, + attributes: {pve_id: 105, role: docker-apps, lan_ip: 192.168.8.205, + age_pubkey: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0, + note: "legacy Oikos host; fallback during cutover (plan A6)"}} + - {slug: "lxc:auth-outpost", type: lxc, name: auth-outpost, + attributes: {pve_id: 106, role: authentik-gateway, lan_ip: 192.168.8.6}} + - {slug: "lxc:dns", type: lxc, name: dns, + attributes: {pve_id: 107, role: dns-server, lan_ip: 192.168.8.2}} + - {slug: "lxc:nextcloud", type: lxc, name: nextcloud, + attributes: {pve_id: 114, role: file-sync, lan_ip: 192.168.8.224, + public_host: cloud.hubris.network}} + - {slug: "lxc:elementsynapse", type: lxc, name: elementsynapse, + attributes: {pve_id: 118, role: matrix-server, lan_ip: 192.168.8.242, + public_host: matrix.hubris.network, + note: "migrated hubris→strong 2026-07-05"}} + - {slug: "lxc:sophia", type: lxc, name: sophia, + attributes: {pve_id: 119, role: workshop, lan_ip: 192.168.8.109}} + - {slug: "lxc:mule-images", type: lxc, name: mule-images, + attributes: {pve_id: 120, role: photo-management, lan_ip: 192.168.8.136, + public_host: photos.hubris.network}} + - {slug: "lxc:caddy", type: lxc, name: caddy, + attributes: {pve_id: 121, role: reverse-proxy, lan_ip: 192.168.8.175, + note: "terminates all *.hubris.network; /etc/caddy is a checkout of dtoro/caddy-conf"}} + - {slug: "lxc:arriman", type: lxc, name: arriman, + attributes: {pve_id: 122, role: arr-stack, lan_ip: 192.168.8.245, + public_hosts: [jellyseerr.hubris.network, qbit.hubris.network, sab.hubris.network], + note: "homarr/radarr/sonarr/lidarr/sab/qbit/bazarr/flaresolverr/prowlarr/jellyseerr; migrated to strong 2026-07-05"}} + - {slug: "lxc:trmnl", type: lxc, name: trmnl, + attributes: {pve_id: 128, role: trmnl-middleware, lan_ip: 192.168.8.211, + public_host: trmnl.hubris.network, + note: "not yet mesh/SOPS-enrolled"}} + - {slug: "lxc:house", type: lxc, name: house, + attributes: {pve_id: 129, role: family-planner, lan_ip: 192.168.8.244, + public_host: house.hubris.network, + age_pubkey: age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h, + note: "Yuvomi + WebDAV bridge to paperless; migrated to strong 2026-07-05"}} + - {slug: "lxc:grimmory", type: lxc, name: grimmory, + attributes: {pve_id: 130, role: book-library, lan_ip: 192.168.8.247, + public_host: books.hubris.network, + age_pubkey: age1uellsemnjrzgfg9fxw4jefpy05laxzggwnwhh6ny3wl7alyp6v8q0muxet}} + - {slug: "lxc:teddycloud", type: lxc, name: teddycloud, + attributes: {pve_id: 131, role: teddycloud, lan_ip: 192.168.8.150, + public_host: teddy.hubris.network, + note: "drift-caught 2026-07-06; no forward-auth gate on route; not a homelab client"}} + - {slug: "lxc:rclone", type: lxc, name: rclone, + attributes: {pve_id: 132, role: backup, + mesh: {netbird: {fqdn: rclone.netbird.selfhosted}}, + age_pubkey: age1pwtdws2thdh7vzp2dzttl3zxgcs2tgpcsjsqgw3q04nyml4kvuqq467u4x}} + # THIN: hosting machine not recorded in legacy inventory — backfill hosts edge + - {slug: "lxc:seanime", type: lxc, name: seanime, + attributes: {pve_id: 133, role: anime-media-server, lan_ip: 192.168.8.248, + public_host: seanime.hubris.network, + note: "systemd service at /opt/seanime; uses qbittorrent on arriman"}} + - {slug: "lxc:romm", type: lxc, name: romm, + attributes: {pve_id: 134, role: rom-manager, lan_ip: 192.168.8.249, + public_host: roms.hubris.network, + note: "docker compose + MariaDB sidecar at /opt/romm"}} + + # ─── VMs ─────────────────────────────────────────────────────────── + - {slug: "vm:zimaos", type: vm, name: zimaos, + attributes: {pve_id: 100, role: nas-frontend-eval, lan_ip: 192.168.8.195, + public_host: zimaos.hubris.network}} + - {slug: "vm:haos", type: vm, name: haos, + attributes: {pve_id: 108, role: home-automation, lan_ip: 192.168.8.101}} + + # ─── Storage ─────────────────────────────────────────────────────── + - {slug: "pool:local-lvm-hubris", type: storage-pool, name: local-lvm (hubris), + attributes: {type: lvm}} + - {slug: "pool:ludo-lvm", type: storage-pool, name: ludo-lvm (strong), + attributes: {type: lvm}} + - {slug: "volume:library", type: volume, name: library, + attributes: {path: /mnt/library}} # THIN: owning pool unrecorded + - {slug: "volume:media-local", type: volume, name: media-local, + attributes: {path: /mnt/media_local}} + - {slug: "backup:proton-drive", type: backup-target, name: proton-drive, + attributes: {provider: proton, encrypted: true}} + + # ─── Services ────────────────────────────────────────────────────── + - {slug: "service:proxmox-ui", type: service, name: proxmox_ui, + attributes: {url: "https://proxmox.hubris.network", port: 8006, + doc_page: knowledge/wiki/hosts/hubris.md, + risk_notes: "hypervisor UI — changes affect every guest on the node"}} + - {slug: "service:gitea", type: service, name: gitea, + attributes: {url: "https://git.hubris.network", port: 3000, + doc_page: knowledge/wiki/containers/104-gitea.md, + risk_notes: "hosts all config repos + deploy webhooks; outage blocks auto-deploy and sync"}} + - {slug: "service:caddy", type: service, name: caddy, + attributes: {doc_page: knowledge/wiki/containers/121-caddy.md, + risk_notes: "wide blast radius — every *.hubris.network route rides on it"}} + - {slug: "service:authentik", type: service, name: authentik, + attributes: {url: "https://auth.hubris.network", + doc_page: knowledge/wiki/containers/106-auth-outpost.md, + note: "core on VPS since 2026-05-31; LAN outpost = auth-outpost (LXC 106) 192.168.8.6:9000", + risk_notes: "SSO provider — outage locks login to OIDC/forward-auth services"}} + - {slug: "service:dns", type: service, name: dns, + attributes: {doc_page: knowledge/wiki/containers/107-dns.md, + risk_notes: "LAN-wide resolver — misconfig breaks name resolution for every client"}} + - {slug: "service:jellyfin", type: service, name: jellyfin, + attributes: {url: "https://media.hubris.network", + doc_page: knowledge/wiki/containers/101-jellyfin.md, + risk_notes: "native Authentik OIDC (no forward-auth gate); VAAPI depends on GPU passthrough on strong"}} + - {slug: "service:nextcloud", type: service, name: nextcloud, + attributes: {url: "https://cloud.hubris.network", + doc_page: knowledge/wiki/containers/114-nextcloud.md}} + - {slug: "service:paperless", type: service, name: paperless, + attributes: {url: "https://paperless.hubris.network", + doc_page: knowledge/wiki/containers/103-paperless.md, + risk_notes: "document archive — data irreplaceable; DB operations are destructive-class"}} + - {slug: "service:matrix", type: service, name: matrix, + attributes: {url: "https://matrix.hubris.network", + doc_page: knowledge/wiki/containers/118-elementsynapse.md, + risk_notes: "alert/approval channel for Oikos — outage silences agent escalation"}} + - {slug: "service:photos", type: service, name: photos, + attributes: {url: "https://photos.hubris.network", + doc_page: knowledge/wiki/containers/120-mule-images.md}} + - {slug: "service:arr-stack", type: service, name: arr_stack, + attributes: {doc_page: knowledge/wiki/containers/122-arriman.md, + note: "jellyseerr / qbit / sab on docker compose"}} + - {slug: "service:artifacto", type: service, name: artifacto, + attributes: {url: "https://artifacto.hubris.network", + doc_page: knowledge/wiki/containers/105-apps.md}} + - {slug: "service:trmnl", type: service, name: trmnl, + attributes: {url: "https://trmnl.hubris.network", + doc_page: knowledge/wiki/containers/128-trmnl.md, + note: "TRMNL e-ink plugin middleware (polled by TRMNL cloud)"}} + - {slug: "service:zimaos", type: service, name: zimaos, + attributes: {url: "https://zimaos.hubris.network", + doc_page: knowledge/wiki/vms/100-zimaos.md}} + - {slug: "service:haos", type: service, name: haos, + attributes: {doc_page: knowledge/wiki/vms/108-haos.md}} + - {slug: "service:teddycloud", type: service, name: teddycloud, + attributes: {url: "https://teddy.hubris.network", + doc_page: knowledge/wiki/containers/131-teddycloud.md, + risk_notes: "no forward-auth gate — reachable by anyone on LAN/mesh"}} + - {slug: "service:homelab-mcp", type: service, name: homelab_mcp, + attributes: {port: 9810, systemd_unit: homelab-mcp, + endpoint: "https://mcp.hubris.network/mcp", + doc_page: knowledge/wiki/infrastructure/homelab-context.md, + risk_notes: "agents' primary read surface — outage degrades every agent to grepping the clone"}} + - {slug: "service:secrets-issuance", type: service, name: secrets_issuance, + attributes: {port: 9820, systemd_unit: secrets-issuance, + endpoint: "https://secrets.hubris.network/issue", + doc_page: .agents/operations/agent-enrollment.md, + risk_notes: "identity issuance — security-sensitive; key operations are destructive-class"}} + # Services derived from hosts.public_host (no legacy services entry): + - {slug: "service:house", type: service, name: house, + attributes: {url: "https://house.hubris.network", note: "Yuvomi family planner (derived)"}} + - {slug: "service:grimmory", type: service, name: grimmory, + attributes: {url: "https://books.hubris.network", note: derived}} + - {slug: "service:seanime", type: service, name: seanime, + attributes: {url: "https://seanime.hubris.network", port: 43211, note: derived}} + - {slug: "service:romm", type: service, name: romm, + attributes: {url: "https://roms.hubris.network", note: derived}} + - {slug: "service:jellyseerr", type: service, name: jellyseerr, + attributes: {url: "https://jellyseerr.hubris.network", note: derived (arriman)}} + - {slug: "service:qbit", type: service, name: qbit, + attributes: {url: "https://qbit.hubris.network", note: derived (arriman)}} + - {slug: "service:sab", type: service, name: sab, + attributes: {url: "https://sab.hubris.network", note: "derived (arriman); forward-auth gated"}} + + # ─── Config repos ────────────────────────────────────────────────── + - {slug: "repo:caddy-conf", type: config-repo, name: dtoro/caddy-conf} + - {slug: "repo:gitea-customizations", type: config-repo, name: dtoro/gitea-customizations} + - {slug: "repo:mule-image", type: config-repo, name: dtoro/mule-image} + - {slug: "repo:artifacto", type: config-repo, name: dtoro/Artifacto} + - {slug: "repo:terminalito", type: config-repo, name: dtoro/terminalito} + - {slug: "repo:homelab-docs", type: config-repo, name: dtoro/Homelab-Docs} + + # ─── Ingress routes (Caddy, *.hubris.network) ────────────────────── + - {slug: "ingress:proxmox.hubris.network", type: ingress-route, name: proxmox.hubris.network} + - {slug: "ingress:git.hubris.network", type: ingress-route, name: git.hubris.network} + - {slug: "ingress:auth.hubris.network", type: ingress-route, name: auth.hubris.network} + - {slug: "ingress:media.hubris.network", type: ingress-route, name: media.hubris.network} + - {slug: "ingress:cloud.hubris.network", type: ingress-route, name: cloud.hubris.network} + - {slug: "ingress:paperless.hubris.network", type: ingress-route, name: paperless.hubris.network, + attributes: {forward_auth: true}} + - {slug: "ingress:matrix.hubris.network", type: ingress-route, name: matrix.hubris.network} + - {slug: "ingress:photos.hubris.network", type: ingress-route, name: photos.hubris.network} + - {slug: "ingress:artifacto.hubris.network", type: ingress-route, name: artifacto.hubris.network} + - {slug: "ingress:trmnl.hubris.network", type: ingress-route, name: trmnl.hubris.network} + - {slug: "ingress:zimaos.hubris.network", type: ingress-route, name: zimaos.hubris.network} + - {slug: "ingress:teddy.hubris.network", type: ingress-route, name: teddy.hubris.network, + attributes: {forward_auth: false}} + - {slug: "ingress:mcp.hubris.network", type: ingress-route, name: mcp.hubris.network} + - {slug: "ingress:secrets.hubris.network", type: ingress-route, name: secrets.hubris.network} + - {slug: "ingress:house.hubris.network", type: ingress-route, name: house.hubris.network} + - {slug: "ingress:books.hubris.network", type: ingress-route, name: books.hubris.network} + - {slug: "ingress:seanime.hubris.network", type: ingress-route, name: seanime.hubris.network} + - {slug: "ingress:roms.hubris.network", type: ingress-route, name: roms.hubris.network} + - {slug: "ingress:jellyseerr.hubris.network", type: ingress-route, name: jellyseerr.hubris.network} + - {slug: "ingress:qbit.hubris.network", type: ingress-route, name: qbit.hubris.network} + - {slug: "ingress:sab.hubris.network", type: ingress-route, name: sab.hubris.network, + attributes: {forward_auth: true}} + + # ─── Governance ──────────────────────────────────────────────────── + - {slug: "person:dtoro", type: person, name: dtoro, + attributes: {matrix_id: "@dtoro:avispero"}} + - {slug: "idp:authentik", type: identity-provider, name: authentik, + attributes: {issuer: "https://auth.hubris.network", auth_mode: both}} + - {slug: "agent:hermes", type: agent, name: hermes, + state: planned, + attributes: {gateway_port: 8092, note: "Oikos Phase 4 — Docker gateway mode"}} + - {slug: "agent:oikos", type: agent, name: oikos, + state: planned, + attributes: {note: "the OS control loop itself (scheduler/actuator) as an actor"}} + + # ─── Archaeology (state: destroyed — kept for "what happened to X?") ─ + - {slug: "lxc:claudio-bot", type: lxc, name: claudio-bot, state: destroyed, + attributes: {pve_id: 123, destroyed: "2026-06-04", reason: "replaced by Hermes Agent on mac-mini"}} + - {slug: "lxc:plato", type: lxc, name: plato, state: destroyed, + attributes: {pve_id: 126, destroyed: "2026-06-28", reason: "notes workspace decommissioned; data at /mnt/library/documents/plato"}} + - {slug: "lxc:mule-photos-new", type: lxc, name: mule-photos-new, state: destroyed, + attributes: {pve_id: 127, destroyed: "2026-05-22", reason: "PhotoPrism test stack promoted to LXC 120"}} + - {slug: "lxc:heaper", type: lxc, name: heaper, state: destroyed, + attributes: {pve_id: 116, destroyed: "2026-05-14", reason: "decommissioned; data at /mnt/library/heaper"}} + - {slug: "lxc:syncthing", type: lxc, name: syncthing, state: destroyed, + attributes: {pve_id: 109, destroyed: "2026-05-14", reason: "decommissioned; library subtree was empty"}} + - {slug: "lxc:seafile", type: lxc, name: seafile, state: destroyed, + attributes: {pve_id: 125, destroyed: "2026-05-13", reason: "Seafile Pro evaluation rejected"}} + - {slug: "lxc:arr-yunohost", type: lxc, name: arr-yunohost, state: destroyed, + attributes: {pve_id: 100, destroyed: "2026-04-28", reason: "migrated to docker stack on arriman (LXC 122)"}} + - {slug: "lxc:flaresolverr", type: lxc, name: flaresolverr, state: destroyed, + attributes: {pve_id: 106, destroyed: "2026-04-28", reason: "folded into the arriman docker compose"}} + - {slug: "lxc:marimo", type: lxc, name: marimo, state: destroyed, + attributes: {pve_id: 107, destroyed: "2026-04-28", reason: decommissioned}} + - {slug: "lxc:photoprism", type: lxc, name: photoprism, state: destroyed, + attributes: {pve_id: 110, destroyed: "2026-04-28", reason: "replaced by mule-images (LXC 120)"}} + - {slug: "lxc:karakeep", type: lxc, name: karakeep, state: destroyed, + attributes: {pve_id: 111, destroyed: "2026-04-28", reason: decommissioned}} + - {slug: "lxc:immich", type: lxc, name: immich, state: destroyed, + attributes: {pve_id: 112, destroyed: "2026-04-28", reason: "replaced by mule-images (LXC 120)"}} + - {slug: "lxc:reticulum", type: lxc, name: reticulum, state: destroyed, + attributes: {pve_id: 115, destroyed: "2026-04-28", reason: decommissioned}} + +relationships: + + # ─── Cluster membership ──────────────────────────────────────────── + - {source: "host:hubris", target: "cluster:homelab", type: member-of} + - {source: "host:strong", target: "cluster:homelab", type: member-of} + + # ─── Location ────────────────────────────────────────────────────── + - {source: "host:hubris", target: "site:home", type: located-at} + - {source: "host:strong", target: "site:home", type: located-at} + - {source: "ws:mac-mini", target: "site:home", type: located-at} + - {source: "host:netbird-vps", target: "site:ionos-dc", type: located-at} + + # ─── Hosting (machine → guest) ───────────────────────────────────── + - {source: "host:hubris", target: "lxc:nfs-export", type: hosts} + - {source: "host:hubris", target: "lxc:paperless", type: hosts} + - {source: "host:hubris", target: "lxc:gitea", type: hosts} + - {source: "host:hubris", target: "lxc:apps", type: hosts} + - {source: "host:hubris", target: "lxc:auth-outpost", type: hosts} + - {source: "host:hubris", target: "lxc:dns", type: hosts} + - {source: "host:hubris", target: "lxc:nextcloud", type: hosts} + - {source: "host:hubris", target: "lxc:sophia", type: hosts} + - {source: "host:hubris", target: "lxc:mule-images", type: hosts} + - {source: "host:hubris", target: "lxc:caddy", type: hosts} + - {source: "host:hubris", target: "lxc:trmnl", type: hosts} + - {source: "host:hubris", target: "lxc:teddycloud", type: hosts} + - {source: "host:hubris", target: "vm:zimaos", type: hosts} + - {source: "host:hubris", target: "vm:haos", type: hosts} + - {source: "host:strong", target: "lxc:jellyfin", type: hosts} + - {source: "host:strong", target: "lxc:elementsynapse", type: hosts} + - {source: "host:strong", target: "lxc:arriman", type: hosts} + - {source: "host:strong", target: "lxc:house", type: hosts} + - {source: "host:strong", target: "lxc:grimmory", type: hosts} + - {source: "host:strong", target: "lxc:seanime", type: hosts} + - {source: "host:strong", target: "lxc:romm", type: hosts} + # THIN: lxc:rclone hosting machine unknown — backfill + + # ─── Service provision (compute → service) ───────────────────────── + - {source: "host:hubris", target: "service:proxmox-ui", type: provides} + - {source: "lxc:gitea", target: "service:gitea", type: provides} + - {source: "lxc:caddy", target: "service:caddy", type: provides} + - {source: "host:netbird-vps", target: "service:authentik", type: provides} + - {source: "lxc:dns", target: "service:dns", type: provides} + - {source: "lxc:jellyfin", target: "service:jellyfin", type: provides} + - {source: "lxc:nextcloud", target: "service:nextcloud", type: provides} + - {source: "lxc:paperless", target: "service:paperless", type: provides} + - {source: "lxc:elementsynapse", target: "service:matrix", type: provides} + - {source: "lxc:mule-images", target: "service:photos", type: provides} + - {source: "lxc:arriman", target: "service:arr-stack", type: provides} + - {source: "lxc:arriman", target: "service:jellyseerr", type: provides} + - {source: "lxc:arriman", target: "service:qbit", type: provides} + - {source: "lxc:arriman", target: "service:sab", type: provides} + - {source: "lxc:apps", target: "service:artifacto", type: provides} + - {source: "lxc:apps", target: "service:homelab-mcp", type: provides} + - {source: "lxc:apps", target: "service:secrets-issuance", type: provides} + - {source: "lxc:trmnl", target: "service:trmnl", type: provides} + - {source: "vm:zimaos", target: "service:zimaos", type: provides} + - {source: "vm:haos", target: "service:haos", type: provides} + - {source: "lxc:teddycloud", target: "service:teddycloud", type: provides} + - {source: "lxc:house", target: "service:house", type: provides} + - {source: "lxc:grimmory", target: "service:grimmory", type: provides} + - {source: "lxc:seanime", target: "service:seanime", type: provides} + - {source: "lxc:romm", target: "service:romm", type: provides} + + # ─── Ingress → service ───────────────────────────────────────────── + - {source: "ingress:proxmox.hubris.network", target: "service:proxmox-ui", type: routes-to} + - {source: "ingress:git.hubris.network", target: "service:gitea", type: routes-to} + - {source: "ingress:auth.hubris.network", target: "service:authentik", type: routes-to} + - {source: "ingress:media.hubris.network", target: "service:jellyfin", type: routes-to} + - {source: "ingress:cloud.hubris.network", target: "service:nextcloud", type: routes-to} + - {source: "ingress:paperless.hubris.network", target: "service:paperless", type: routes-to} + - {source: "ingress:matrix.hubris.network", target: "service:matrix", type: routes-to} + - {source: "ingress:photos.hubris.network", target: "service:photos", type: routes-to} + - {source: "ingress:artifacto.hubris.network", target: "service:artifacto", type: routes-to} + - {source: "ingress:trmnl.hubris.network", target: "service:trmnl", type: routes-to} + - {source: "ingress:zimaos.hubris.network", target: "service:zimaos", type: routes-to} + - {source: "ingress:teddy.hubris.network", target: "service:teddycloud", type: routes-to} + - {source: "ingress:mcp.hubris.network", target: "service:homelab-mcp", type: routes-to} + - {source: "ingress:secrets.hubris.network", target: "service:secrets-issuance", type: routes-to} + - {source: "ingress:house.hubris.network", target: "service:house", type: routes-to} + - {source: "ingress:books.hubris.network", target: "service:grimmory", type: routes-to} + - {source: "ingress:seanime.hubris.network", target: "service:seanime", type: routes-to} + - {source: "ingress:roms.hubris.network", target: "service:romm", type: routes-to} + - {source: "ingress:jellyseerr.hubris.network", target: "service:jellyseerr", type: routes-to} + - {source: "ingress:qbit.hubris.network", target: "service:qbit", type: routes-to} + - {source: "ingress:sab.hubris.network", target: "service:sab", type: routes-to} + + # ─── Auth edges ──────────────────────────────────────────────────── + - {source: "ingress:paperless.hubris.network", target: "idp:authentik", type: secured-by} + - {source: "ingress:sab.hubris.network", target: "idp:authentik", type: secured-by} + - {source: "service:jellyfin", target: "idp:authentik", type: authenticates-via} + - {source: "idp:authentik", target: "person:dtoro", type: authenticates} + + # ─── Config repos ────────────────────────────────────────────────── + - {source: "service:caddy", target: "repo:caddy-conf", type: configured-by} + - {source: "service:gitea", target: "repo:gitea-customizations", type: configured-by} + - {source: "service:photos", target: "repo:mule-image", type: configured-by} + - {source: "service:artifacto", target: "repo:artifacto", type: configured-by} + - {source: "service:trmnl", target: "repo:terminalito", type: configured-by} + - {source: "service:homelab-mcp", target: "repo:homelab-docs", type: configured-by} + - {source: "service:secrets-issuance", target: "repo:homelab-docs", type: configured-by} + + # ─── Service dependencies (blast-radius edges; grow over time) ───── + - {source: "service:paperless", target: "service:authentik", type: depends-on} + - {source: "service:homelab-mcp", target: "service:gitea", type: depends-on} + - {source: "service:jellyseerr", target: "service:jellyfin", type: depends-on} + - {source: "service:seanime", target: "service:qbit", type: depends-on} + - {source: "service:house", target: "service:paperless", type: depends-on} + - {source: "service:sab", target: "service:authentik", type: depends-on} + + # ─── Network membership ──────────────────────────────────────────── + - {source: "host:hubris", target: "lan:lab", type: connects-via} + - {source: "host:hubris", target: "mesh:netbird", type: connects-via} + - {source: "host:strong", target: "lan:household", type: connects-via} + - {source: "ws:mac-mini", target: "lan:household", type: connects-via} + - {source: "ws:mac-mini", target: "mesh:netbird", type: connects-via} + - {source: "ws:republic-laptop", target: "mesh:netbird", type: connects-via} + - {source: "host:netbird-vps", target: "mesh:netbird", type: connects-via} + - {source: "lxc:rclone", target: "mesh:netbird", type: connects-via} + - {source: "lxc:jellyfin", target: "lan:lab", type: connects-via} + - {source: "lxc:nfs-export", target: "lan:lab", type: connects-via} + - {source: "lxc:paperless", target: "lan:lab", type: connects-via} + - {source: "lxc:gitea", target: "lan:lab", type: connects-via} + - {source: "lxc:apps", target: "lan:lab", type: connects-via} + - {source: "lxc:apps", target: "mesh:tailscale", type: connects-via} + - {source: "lxc:auth-outpost", target: "lan:lab", type: connects-via} + - {source: "lxc:dns", target: "lan:lab", type: connects-via} + - {source: "lxc:nextcloud", target: "lan:lab", type: connects-via} + - {source: "lxc:elementsynapse", target: "lan:lab", type: connects-via} + - {source: "lxc:sophia", target: "lan:lab", type: connects-via} + - {source: "lxc:mule-images", target: "lan:lab", type: connects-via} + - {source: "lxc:caddy", target: "lan:lab", type: connects-via} + - {source: "lxc:arriman", target: "lan:lab", type: connects-via} + - {source: "lxc:trmnl", target: "lan:lab", type: connects-via} + - {source: "lxc:house", target: "lan:lab", type: connects-via} + - {source: "lxc:grimmory", target: "lan:lab", type: connects-via} + - {source: "lxc:teddycloud", target: "lan:lab", type: connects-via} + - {source: "lxc:seanime", target: "lan:lab", type: connects-via} + - {source: "lxc:romm", target: "lan:lab", type: connects-via} + - {source: "vm:zimaos", target: "lan:lab", type: connects-via} + - {source: "vm:haos", target: "lan:lab", type: connects-via} + + # ─── Storage ─────────────────────────────────────────────────────── + - {source: "pool:ludo-lvm", target: "volume:media-local", type: contains} + - {source: "host:hubris", target: "volume:library", type: mounts, + attributes: {mount_point: /mnt/library}} + - {source: "lxc:jellyfin", target: "volume:media-local", type: mounts, + attributes: {mount_point: /mnt/media_local}} + - {source: "lxc:paperless", target: "volume:library", type: mounts, + attributes: {mount_point: /mnt/library}} + - {source: "lxc:gitea", target: "volume:library", type: mounts, + attributes: {mount_point: /mnt/library}} + - {source: "lxc:apps", target: "volume:library", type: mounts, + attributes: {mount_point: /mnt/library}} + - {source: "lxc:nextcloud", target: "volume:library", type: mounts, + attributes: {mount_point: /mnt/library}} + - {source: "lxc:sophia", target: "volume:library", type: mounts, + attributes: {mount_point: /mnt/library}} + - {source: "lxc:mule-images", target: "volume:library", type: mounts, + attributes: {mount_point: /mnt/library}} + - {source: "lxc:arriman", target: "volume:media-local", type: mounts, + attributes: {mount_point: /mnt/media_local}} + - {source: "lxc:grimmory", target: "volume:media-local", type: mounts, + attributes: {mount_point: /mnt/media_local}} + - {source: "lxc:teddycloud", target: "volume:library", type: mounts, + attributes: {mount_point: /mnt/library}} + - {source: "lxc:seanime", target: "volume:media-local", type: mounts, + attributes: {mount_point: /mnt/media_local/anime}} + - {source: "lxc:romm", target: "volume:media-local", type: mounts, + attributes: {mount_point: /mnt/media_local}} + - {source: "lxc:jellyfin", target: "pool:ludo-lvm", type: stores-on} + - {source: "lxc:arriman", target: "pool:ludo-lvm", type: stores-on} + - {source: "lxc:grimmory", target: "pool:ludo-lvm", type: stores-on} + - {source: "lxc:romm", target: "pool:ludo-lvm", type: stores-on} + - {source: "lxc:teddycloud", target: "pool:local-lvm-hubris", type: stores-on} + - {source: "lxc:rclone", target: "backup:proton-drive", type: backs-up-to} + + # ─── Governance ──────────────────────────────────────────────────── + - {source: "person:dtoro", target: "agent:hermes", type: owns} + - {source: "person:dtoro", target: "agent:oikos", type: owns} diff --git a/seeds/ontology.yaml b/seeds/ontology.yaml new file mode 100644 index 0000000..a6e279d --- /dev/null +++ b/seeds/ontology.yaml @@ -0,0 +1,936 @@ +# Oikos ontology seed — the systems model of the homelab. +# +# Bootstraps entity_types / relationship_types / lifecycle_defs on first +# deploy (migration 001). After ingest the DB is authoritative; this file +# is regenerated by `GET /api/v1/export` for DR + version control. +# +# Conventions: +# - entity type names are kebab-case +# - `parent:` builds the is-a hierarchy; `abstract: true` types cannot be +# instantiated (validation walks the hierarchy for relationship +# endpoints and policy rules — plan R3-1) +# - `layer:` one of meta | infrastructure | governance | cognition +# - relationship `cardinality:` describes source→target multiplicity: +# one-to-one | one-to-many | many-to-one | many-to-many +# - relationship endpoints may name abstract types +# - lifecycle transition `requires:` entries are NAMED CHECKS implemented +# in Go (internal/ontology); the DB stores which checks gate a +# transition, the code implements them +# - mount details (mount_point, options) live as ATTRIBUTES on `mounts` +# edges, not as a separate entity type +# +# Rule of completeness: if something can break, be changed, or hold data, +# it has an entity type here and edges to the things it touches. + +version: 1 + +# ─── Lifecycles ──────────────────────────────────────────────────────── + +lifecycles: + infrastructure: + states: [planned, provisioning, active, migrating, failed, deprecated, destroyed] + default_state: active # legacy inventory entries without state are active + terminal_states: [destroyed] + transitions: + planned: + provisioning: {requires: [inventory-entry, ip-reserved, storage-pool-chosen, doc-page-stub]} + destroyed: {requires: [cancelled-note]} + provisioning: + active: {requires: [age-key-enrolled-if-needed, mesh-joined-if-needed, + ingress-live-if-public, health-check-answering, + doc-page-complete]} + failed: {requires: []} + active: + migrating: {requires: [preflight, backup-verified]} + deprecated: {requires: [replacement-live-or-role-retired]} + failed: {requires: []} + migrating: + active: {requires: [post-verify, caddy-backends-checked, mounts-checked, docs-updated]} + failed: {requires: []} + failed: + active: {requires: [recovery-verified]} + deprecated: {requires: [write-off-note]} + deprecated: + active: {requires: [un-deprecate-note]} + destroyed: {requires: [backups-verified, secrets-revoked-and-rekeyed, + ingress-and-dns-removed, no-inbound-edges, + archaeology-entry]} + + signal: + states: [raised, acknowledged, acting, muted, resolved, failed] + default_state: raised + terminal_states: [resolved] + transitions: + raised: + acknowledged: {requires: []} + muted: {requires: [mute-ttl-set]} + resolved: {requires: [condition-cleared]} + acknowledged: + acting: {requires: [classification-exists]} + resolved: {requires: []} + muted: {requires: [mute-ttl-set]} + acting: + resolved: {requires: [verification-passed]} + raised: {requires: [retry-budget-remaining]} + failed: {requires: []} + failed: + acknowledged: {requires: [operator-retry]} + muted: + raised: {requires: [mute-ttl-expired]} + + execution: + states: [proposed, approved, auto_approved, denied, expired, executing, + verifying, verified, failed, timed_out, cancelled, rolled_back, + rollback_failed] + default_state: proposed + terminal_states: [verified, failed, denied, expired, cancelled, + rolled_back, rollback_failed] + transitions: + proposed: + approved: {requires: [operator-approval]} + auto_approved: {requires: [autonomy-allows]} + denied: {requires: []} + approved: + executing: {requires: [approval-token-valid]} + expired: {requires: [approval-ttl-elapsed]} + auto_approved: + executing: {requires: []} + executing: + verified: {requires: [verification-passed]} + failed: {requires: []} + timed_out: {requires: []} + cancelled: {requires: [operator-abort]} + timed_out: + verifying: {requires: []} # check if the command completed anyway + verifying: + verified: {requires: [verification-passed]} + failed: {requires: []} + failed: + rolled_back: {requires: [rollback-procedure-exists]} + rollback_failed: {requires: []} + + approval: + states: [pending, approved, denied, expired, revoked] + default_state: pending + terminal_states: [denied, expired, revoked] + transitions: + pending: + approved: {requires: [token-verified]} + denied: {requires: []} + expired: {requires: [ttl-elapsed]} + approved: + revoked: {requires: [not-yet-executing]} + + pattern: + states: [hypothesized, validated, active, deprecated, invalidated] + default_state: hypothesized + terminal_states: [deprecated, invalidated] + transitions: + hypothesized: + validated: {requires: [evidence-count-5plus, confidence-0.7plus]} + invalidated: {requires: []} + validated: + active: {requires: [operator-approval]} # S4: never automatic + invalidated: {requires: []} + active: + deprecated: {requires: []} + invalidated: {requires: [contradicting-evidence]} + + skill: + states: [drafted, tested, active, refined, failed, deprecated] + default_state: drafted + terminal_states: [deprecated] + transitions: + drafted: + tested: {requires: [test-execution-recorded]} + deprecated: {requires: []} + tested: + active: {requires: [operator-approval]} + failed: {requires: []} + failed: + drafted: {requires: []} + active: + refined: {requires: [new-version-created]} + deprecated: {requires: []} + refined: + active: {requires: [operator-approval]} + +# ─── Entity types ────────────────────────────────────────────────────── +# domain: physical | compute | network | storage | software | external | +# identity | cognition + +entity_types: + + # Root + entity: + abstract: true + domain: meta + layer: meta + description: Root abstract type. Relationship endpoints that accept any + entity (documented-by, procedure-for, checks) reference this. + + # ── Infrastructure / physical ── + site: + parent: entity + domain: physical + layer: infrastructure + lifecycle: infrastructure + description: Physical location (home, VPS datacenter). + attributes: {type: object, properties: {address: {type: string}}} + ups: + parent: entity + domain: physical + layer: infrastructure + lifecycle: infrastructure + description: Uninterruptible power supply. + attributes: {type: object, properties: {vendor: {type: string}, va: {type: integer}}} + sensor: + parent: entity + domain: physical + layer: infrastructure + lifecycle: infrastructure + description: Environmental sensor. + peripheral: + parent: entity + domain: physical + layer: infrastructure + lifecycle: infrastructure + description: Attached hardware (GPU, e-ink display, dongle). + + # ── Infrastructure / compute ── + compute-entity: + parent: entity + abstract: true + domain: compute + layer: infrastructure + description: Anything that executes workloads (machine, VM, container). + machine: + parent: compute-entity + abstract: true + domain: compute + layer: infrastructure + description: Physical machine. Always instantiated as a subtype. + attributes: + type: object + properties: + cpu_arch: {type: string} + ram_gb: {type: number} + os: {type: string, enum: [linux, macos]} + lan_ip: {type: string} + mesh: {type: object} + ssh: {type: object} + age_pubkey: {type: string} + proxmox-host: + parent: machine + domain: compute + layer: infrastructure + lifecycle: infrastructure + description: Machine running Proxmox VE. + attributes: + type: object + properties: {pve_version: {type: string}} + standalone-server: + parent: machine + domain: compute + layer: infrastructure + lifecycle: infrastructure + description: Machine outside PVE management (e.g. external VPS). + attributes: + type: object + properties: + hypervisor: {type: string} + provider: {type: string} + control_level: {type: string, enum: [full, partial, none]} + public_ipv4: {type: string} + workstation: + parent: machine + domain: compute + layer: infrastructure + lifecycle: infrastructure + description: Operator machine (may also host services, e.g. mac-mini). + attributes: + type: object + properties: {user: {type: string}} + appliance: + parent: machine + domain: compute + layer: infrastructure + lifecycle: infrastructure + description: Vendor appliance with limited management access. + attributes: + type: object + properties: {vendor: {type: string}, model: {type: string}} + vm: + parent: compute-entity + domain: compute + layer: infrastructure + lifecycle: infrastructure + description: Virtual machine. + attributes: + type: object + properties: + pve_id: {type: integer} + vcpus: {type: integer} + memory_mb: {type: integer} + lan_ip: {type: string} + public_host: {type: string} + role: {type: string} + container: + parent: compute-entity + abstract: true + domain: compute + layer: infrastructure + description: OS-level container (LXC or Docker). + attributes: + type: object + properties: {runtime: {type: string}} + lxc: + parent: container + domain: compute + layer: infrastructure + lifecycle: infrastructure + description: Proxmox LXC container. + attributes: + type: object + properties: + pve_id: {type: integer} + lan_ip: {type: string} + public_host: {type: string} + public_hosts: {type: array, items: {type: string}} + role: {type: string} + mesh: {type: object} + age_pubkey: {type: string} + destroyed: {type: string} + reason: {type: string} + docker-container: + parent: container + domain: compute + layer: infrastructure + lifecycle: infrastructure + description: Docker container (the OS models its own stack with these). + attributes: + type: object + properties: {image: {type: string}} + hypervisor: + parent: entity + domain: compute + layer: infrastructure + lifecycle: infrastructure + description: Hypervisor software running on a machine (PVE, KVM, OrbStack). + attributes: + type: object + properties: {type: {type: string}, version: {type: string}} + + # ── Infrastructure / network ── + network: + parent: entity + abstract: true + domain: network + layer: infrastructure + description: A network things connect to. + lan: + parent: network + domain: network + layer: infrastructure + lifecycle: infrastructure + description: Local area network. + attributes: {type: object, properties: {subnet: {type: string}}} + mesh: + parent: network + domain: network + layer: infrastructure + lifecycle: infrastructure + description: Overlay mesh network (NetBird, Tailscale). + attributes: + type: object + properties: + provider: {type: string} + subnet: {type: string} + domain: {type: string} + vlan: + parent: network + domain: network + layer: infrastructure + lifecycle: infrastructure + description: Tagged VLAN. + attributes: {type: object, properties: {tag: {type: integer}}} + network-interface: + parent: entity + domain: network + layer: infrastructure + description: Optional per-interface refinement (mac, ip). The seed uses + coarse connects-via edges; interfaces can be backfilled later. + attributes: {type: object, properties: {mac: {type: string}, ip: {type: string}}} + dns-zone: + parent: entity + domain: network + layer: infrastructure + lifecycle: infrastructure + description: DNS zone (e.g. split-horizon hubris.network). + attributes: {type: object, properties: {zone: {type: string}, authority: {type: string}}} + dns-record: + parent: entity + domain: network + layer: infrastructure + description: Individual DNS record. + attributes: + type: object + properties: {name: {type: string}, record_type: {type: string}, value: {type: string}} + ingress-route: + parent: entity + domain: network + layer: infrastructure + lifecycle: infrastructure + description: Public hostname → upstream mapping (Caddy). + attributes: + type: object + properties: + pattern: {type: string} + upstream: {type: string} + forward_auth: {type: boolean} + certificate: + parent: entity + domain: network + layer: infrastructure + description: TLS certificate. + attributes: {type: object, properties: {issuer: {type: string}, expires: {type: string}}} + firewall-rule: + parent: entity + domain: network + layer: infrastructure + description: Firewall / port-forward rule. + + # ── Infrastructure / storage ── + storage-pool: + parent: entity + domain: storage + layer: infrastructure + lifecycle: infrastructure + description: Storage pool (LVM, ZFS, NFS). + attributes: + type: object + properties: + type: {type: string} + capacity_gb: {type: number} + volume: + parent: entity + domain: storage + layer: infrastructure + lifecycle: infrastructure + description: Named volume / dataset within a pool. Mount details live as + attributes on `mounts` edges. + attributes: {type: object, properties: {size_gb: {type: number}, path: {type: string}}} + backup-target: + parent: entity + domain: storage + layer: infrastructure + lifecycle: infrastructure + description: Where backups land (Proton Drive, PBS). + attributes: {type: object, properties: {provider: {type: string}, encrypted: {type: boolean}}} + dataset: + parent: entity + domain: storage + layer: infrastructure + description: Logical data collection worth tracking independently of its + volume (e.g. paperless documents). + + # ── Infrastructure / software ── + service: + parent: entity + domain: software + layer: infrastructure + lifecycle: infrastructure + description: A running service with consumers. + attributes: + type: object + properties: + url: {type: string} + port: {type: integer} + health: {type: string} + endpoint: {type: string} + systemd_unit: {type: string} + doc_page: {type: string} + risk_notes: {type: string} + note: {type: string} + application: + parent: entity + domain: software + layer: infrastructure + description: Deployed application/package a service runs. + attributes: {type: object, properties: {version: {type: string}}} + config-repo: + parent: entity + domain: software + layer: infrastructure + description: Git repo holding tracked configuration. + attributes: + type: object + properties: {url: {type: string}, branch: {type: string}} + deploy-pipeline: + parent: entity + domain: software + layer: infrastructure + description: Automated deploy path (webhook → script). + attributes: + type: object + properties: {trigger: {type: string}, target_path: {type: string}} + package-set: + parent: entity + domain: software + layer: infrastructure + description: Managed package baseline for a host class. + cluster: + parent: entity + domain: software + layer: infrastructure + lifecycle: infrastructure + description: Proxmox cluster. + attributes: {type: object, properties: {quorum: {type: string}}} + compose-stack: + parent: entity + domain: software + layer: infrastructure + lifecycle: infrastructure + description: Docker Compose stack (the Oikos OS itself is one). + attributes: {type: object, properties: {path: {type: string}}} + + # ── Infrastructure / external ── + domain-registration: + parent: entity + domain: external + layer: infrastructure + description: Registered public domain. + attributes: {type: object, properties: {registrar: {type: string}, expires: {type: string}}} + cloud-service: + parent: entity + domain: external + layer: infrastructure + description: External SaaS/cloud dependency. + isp-link: + parent: entity + domain: external + layer: infrastructure + description: Internet uplink. + vendor-dependency: + parent: entity + domain: external + layer: infrastructure + description: Vendor the lab depends on (registrar, IONOS, Proton). + + # ── Governance / identity ── + person: + parent: entity + domain: identity + layer: governance + description: Human actor (operator). + attributes: + type: object + properties: {matrix_id: {type: string}, oidc_sub: {type: string}, email: {type: string}} + agent: + parent: entity + domain: identity + layer: governance + lifecycle: infrastructure # agents are deployed/retired like infrastructure + description: Software agent actor (Hermes, the Oikos control loop). + attributes: + type: object + properties: + provider: {type: string} + model: {type: string} + gateway_port: {type: integer} + identity-provider: + parent: entity + domain: identity + layer: governance + description: OIDC / forward-auth provider (Authentik). + attributes: + type: object + properties: + issuer: {type: string} + client_id: {type: string} + auth_mode: {type: string, enum: [oidc, forward-auth, both]} + account: + parent: entity + domain: identity + layer: governance + description: An account a person/agent holds on a service. + secret: + parent: entity + domain: identity + layer: governance + description: Managed secret (Infisical path). + attributes: + type: object + properties: {path: {type: string}, rotation_days: {type: integer}} + key: + parent: entity + domain: identity + layer: governance + description: Cryptographic key (SSH, age). + access-grant: + parent: entity + domain: identity + layer: governance + description: Grant of access to a secret/scope. + attributes: {type: object, properties: {scope: {type: string}, expires: {type: string}}} + + # ── Cognition ── + check: + parent: entity + domain: cognition + layer: cognition + description: Probe definition (checks-as-data, R3-7). Typed row in check_defs. + signal: + parent: entity + domain: cognition + layer: cognition + lifecycle: signal + description: Something needing attention. Typed row in signals. + classification: + parent: entity + domain: cognition + layer: cognition + description: A classifier decision with full reasoning. Typed row in classifications. + execution: + parent: entity + domain: cognition + layer: cognition + lifecycle: execution + description: An action the OS performed. Typed row in executions. + feedback: + parent: entity + domain: cognition + layer: cognition + description: What was learned from an execution. Typed row in feedback. + pattern: + parent: entity + domain: cognition + layer: cognition + lifecycle: pattern + description: Generalized rule extracted from feedback. Typed row in patterns. + skill: + parent: entity + domain: cognition + layer: cognition + lifecycle: skill + description: Codified, versioned procedure. Typed rows in skills. + approval: + parent: entity + domain: cognition + layer: cognition + lifecycle: approval + description: Operator approval request/decision. Typed row in approvals. + document: + parent: entity + domain: cognition + layer: cognition + description: Knowledge document ingested from docs/. + attributes: + type: object + properties: {title: {type: string}, source_path: {type: string}, content_hash: {type: string}} + runbook: + parent: entity + domain: cognition + layer: cognition + description: Step-by-step procedure for an entity/action. + attributes: + type: object + properties: {risk_class: {type: string}, source_path: {type: string}} + investigation: + parent: entity + domain: cognition + layer: cognition + description: Recorded investigation/postmortem. + +# ─── Relationship types ──────────────────────────────────────────────── +# cardinality is source→target: e.g. `hosts` one-to-many = one machine +# hosts many compute entities; each hosted entity has one hosting machine. + +relationship_types: + + # Infrastructure topology + hosts: + inverse: runs-on + source: machine + target: compute-entity + cardinality: one-to-many + description: Machine hosts a VM/container (hubris hosts lxc:apps). + runs-hypervisor: + inverse: hypervisor-on + source: machine + target: hypervisor + cardinality: one-to-one + description: Machine runs hypervisor software. + member-of: + inverse: has-member + source: proxmox-host + target: cluster + cardinality: many-to-one + description: PVE host belongs to a cluster. + part-of: + inverse: comprises + source: docker-container + target: compose-stack + cardinality: many-to-one + description: Docker container belongs to a compose stack. + provides: + inverse: provided-by + source: compute-entity + target: service + cardinality: one-to-many + description: Compute entity provides a service (lxc:gitea provides service:gitea). + runs: + inverse: run-by + source: service + target: application + cardinality: one-to-many + description: Service runs an application. + configured-by: + inverse: configures + source: entity + target: config-repo + cardinality: many-to-one + description: Entity's config is tracked in a repo (mutations = commit+push). + deploys-to: + inverse: deployed-by + source: deploy-pipeline + target: entity + cardinality: many-to-one + description: Pipeline deploys to a service/host. + routes-to: + inverse: routed-via + source: ingress-route + target: service + cardinality: many-to-one + description: Public hostname routes to a service. + secured-by: + inverse: secures + source: ingress-route + target: identity-provider + cardinality: many-to-one + description: Route gated by forward-auth. + uses-certificate: + inverse: certifies + source: ingress-route + target: certificate + cardinality: many-to-one + description: Route served with this certificate. + authenticates-via: + inverse: authenticates-service + source: service + target: identity-provider + cardinality: many-to-one + description: Service uses native OIDC (jellyfin authenticates-via authentik). + in-zone: + inverse: contains-record + source: dns-record + target: dns-zone + cardinality: many-to-one + description: Record belongs to a zone. + resolves-to: + inverse: resolved-from + source: dns-record + target: entity + cardinality: many-to-one + description: Record points at an ingress route or host. + depends-on: + inverse: dependency-of + source: service + target: service + cardinality: many-to-many + description: Runtime dependency (blast-radius edge). + connects-via: + inverse: connects + source: compute-entity + target: network + cardinality: many-to-many + description: Coarse network membership (host on LAN / mesh). + has-interface: + inverse: interface-of + source: compute-entity + target: network-interface + cardinality: one-to-many + description: Optional per-interface refinement. + interface-on: + inverse: has-endpoint + source: network-interface + target: network + cardinality: many-to-one + description: Interface attaches to a network. + + # Storage + mounts: + inverse: mounted-by + source: compute-entity + target: volume + cardinality: many-to-many + description: Compute entity mounts a volume. Edge attributes carry + mount_point and options. + stores-on: + inverse: stores-for + source: compute-entity + target: storage-pool + cardinality: many-to-many + description: Rootfs/data lives on a pool. + contains: + inverse: contained-in + source: storage-pool + target: volume + cardinality: one-to-many + description: Pool contains a volume. + holds-dataset: + inverse: dataset-on + source: volume + target: dataset + cardinality: one-to-many + description: Volume holds a tracked dataset. + backs-up-to: + inverse: backup-of + source: entity + target: backup-target + cardinality: many-to-many + description: Entity's data is backed up to a target. + + # Physical / external + powered-by: + inverse: powers + source: machine + target: ups + cardinality: many-to-one + description: Machine on UPS power. + located-at: + inverse: location-of + source: machine + target: site + cardinality: many-to-one + description: Machine's physical site. + registered-with: + inverse: registrar-of + source: domain-registration + target: vendor-dependency + cardinality: many-to-one + description: Domain registered with a registrar. + + # Governance + owns: + inverse: owned-by + source: person + target: agent + cardinality: one-to-many + description: Person owns/controls an agent. + authenticates: + inverse: authenticated-by + source: identity-provider + target: person + cardinality: one-to-many + description: IdP authenticates a person. + holds-grant: + inverse: granted-to + source: agent + target: access-grant + cardinality: one-to-many + description: Agent holds an access grant. + grants: + inverse: granted-by + source: access-grant + target: secret + cardinality: many-to-one + description: Grant covers a secret. + can-decrypt: + inverse: readable-by + source: compute-entity + target: secret + cardinality: many-to-many + description: Host can decrypt a secret (legacy SOPS; Infisical grants later). + + # Cognition + checks: + inverse: checked-by + source: check + target: entity + cardinality: many-to-one + description: Check probes an entity. + raises: + inverse: raised-by + source: check + target: signal + cardinality: one-to-many + description: Check raised a signal. + about: + inverse: subject-of + source: signal + target: entity + cardinality: many-to-one + description: Signal concerns an entity. + classifies: + inverse: classified-as + source: classification + target: signal + cardinality: many-to-one + description: Classification of a signal. + precedes: + inverse: follows + source: classification + target: execution + cardinality: one-to-one + description: Classification that led to an execution. + targets: + inverse: targeted-by + source: execution + target: entity + cardinality: many-to-one + description: Execution acts on an entity. + requires-approval: + inverse: approves + source: execution + target: approval + cardinality: one-to-one + description: Execution gated by an approval. + performs: + inverse: performed-by + source: agent + target: execution + cardinality: one-to-many + description: Agent performed an execution. + decides: + inverse: decided-by + source: person + target: approval + cardinality: one-to-many + description: Person decided an approval. + produces: + inverse: produced-by + source: execution + target: feedback + cardinality: one-to-one + description: Execution produced feedback. + contributes-to: + inverse: built-from + source: feedback + target: pattern + cardinality: many-to-many + description: Feedback supports a pattern. + informs: + inverse: informed-by + source: pattern + target: skill + cardinality: many-to-one + description: Pattern informs a skill. + guides: + inverse: guided-by + source: skill + target: classification + cardinality: one-to-many + description: Skill guided a classification. + documents: + inverse: documented-by + source: document + target: entity + cardinality: many-to-one + description: Document describes an entity. + procedure-for: + inverse: has-procedure + source: runbook + target: entity + cardinality: many-to-many + description: Runbook applies to an entity. diff --git a/seeds/policy.yaml b/seeds/policy.yaml new file mode 100644 index 0000000..82453a5 --- /dev/null +++ b/seeds/policy.yaml @@ -0,0 +1,103 @@ +# Oikos policy seed — risk classes, approval rules, autonomy settings. +# +# Bootstraps risk_classes / approval_rules / autonomy_settings (migration +# 005). After ingest the DB is authoritative; runtime policy changes go +# through the dual-control meta-approval flow (plan S3) and are exported +# back here via `GET /api/v1/export`. +# +# Adapted from legacy oikos/policy.yaml (2026-07-07): +# - `commands:`/`mcp_tools:` maps are gone — read endpoints and MCP read +# tools are read_only by construction and never classified; mutating +# API calls classify via approval_rules below. +# - `actions:` map became approval_rules keyed on (entity_type, action); +# entity types may be abstract (rule inherits down the hierarchy, +# most-specific match wins: scope_entity > concrete type > ancestor). +# - `service_overrides:` became scope_entity rules. +# - `lifecycle_overrides:` became autonomy_settings keys read by the +# classifier. + +version: 1 + +risk_classes: + read_only: + description: Observes state; cannot change anything. + approval_required: none + autonomy_allowed: true + reversible_low: + description: >- + Changes runtime state in a way a single follow-up command undoes + (restart, cache clear, sync pull). No config or data changes. + approval_required: none # still subject to global.auto_act + rules below + autonomy_allowed: true + config_mutation: + description: >- + Changes tracked configuration or deployed software: repo edit + push, + deploy trigger, Caddy/Gitea/app config, package upgrades. Reversible + via git, but affects other consumers. + approval_required: operator # Matrix ✅/❌ (single-use HMAC token) + autonomy_allowed: false + destructive: + description: >- + Destroys or irreversibly alters data/entities: container destroy, + disk format, DB wipe, secret rotation, client revocation. + approval_required: operator_confirmed + autonomy_allowed: false + +approval_rules: + # ── Generic rules on (possibly abstract) types ── + - {entity_type: service, action: restart, risk_class: reversible_low, autonomy_level: auto} + - {entity_type: service, action: cache-clear, risk_class: reversible_low, autonomy_level: auto} + - {entity_type: service, action: sync-pull, risk_class: reversible_low, autonomy_level: auto} + - {entity_type: service, action: db-wipe, risk_class: destructive, autonomy_level: never} + - {entity_type: compose-stack, action: restart, risk_class: reversible_low, autonomy_level: auto} + - {entity_type: docker-container, action: restart, risk_class: reversible_low, autonomy_level: auto} + - {entity_type: machine, action: apt-upgrade, risk_class: config_mutation, autonomy_level: escalate} + - {entity_type: machine, action: reboot, risk_class: config_mutation, autonomy_level: escalate} + - {entity_type: machine, action: format-disk, risk_class: destructive, autonomy_level: never} + - {entity_type: config-repo, action: edit, risk_class: config_mutation, autonomy_level: escalate} + - {entity_type: deploy-pipeline, action: trigger, risk_class: config_mutation, autonomy_level: escalate} + - {entity_type: lxc, action: create, risk_class: config_mutation, autonomy_level: escalate} + - {entity_type: lxc, action: migrate, risk_class: config_mutation, autonomy_level: escalate} + - {entity_type: lxc, action: restart, risk_class: reversible_low, autonomy_level: auto} + - {entity_type: lxc, action: destroy, risk_class: destructive, autonomy_level: never} + - {entity_type: vm, action: restart, risk_class: reversible_low, autonomy_level: auto} + - {entity_type: vm, action: destroy, risk_class: destructive, autonomy_level: never} + - {entity_type: dns-record, action: change, risk_class: config_mutation, autonomy_level: escalate} + - {entity_type: ingress-route, action: change, risk_class: config_mutation, autonomy_level: escalate} + - {entity_type: storage-pool, action: change, risk_class: destructive, autonomy_level: never} + - {entity_type: secret, action: rotate, risk_class: destructive, autonomy_level: never} + - {entity_type: key, action: revoke, risk_class: destructive, autonomy_level: never} + + # ── Governance objects (the OS's own levers — always operator-gated) ── + - {entity_type: pattern, action: activate, risk_class: config_mutation, autonomy_level: never} + - {entity_type: skill, action: activate, risk_class: config_mutation, autonomy_level: never} + + # ── Per-entity overrides (wide blast radius) ── + - {entity_type: service, action: restart, scope_entity: "service:caddy", + risk_class: config_mutation, autonomy_level: escalate} + # everything *.hubris.network rides on caddy + - {entity_type: service, action: restart, scope_entity: "service:dns", + risk_class: config_mutation, autonomy_level: escalate} + # LAN-wide resolver + - {entity_type: service, action: restart, scope_entity: "service:authentik", + risk_class: config_mutation, autonomy_level: escalate} + # SSO provider — restart locks logins fleet-wide + +autonomy_settings: + # Global kill-switch. Cold start = off: the agent escalates everything + # until patterns validate and the operator raises this (plan: trust is earned). + global.auto_act: "off" # off | reversible_low + + # Per-entity hard blocks (checked even when global.auto_act is on) + never_auto_act.service:caddy: "true" + never_auto_act.service:dns: "true" + never_auto_act.service:authentik: "true" + never_auto_act.host:hubris: "true" + never_auto_act.host:strong: "true" + + # Lifecycle-state classifier overrides (from legacy lifecycle_overrides) + lifecycle_override.provisioning.config_mutation: reversible_low + # no dependents yet — config changes are cheap + lifecycle_override.deprecated.refuse: new-inbound-edges + lifecycle_override.destroyed.refuse: all + # any action targeting a destroyed entity raises a drift signal instead