diff --git a/SLICE1_DEMO.md b/SLICE1_DEMO.md new file mode 100644 index 0000000..b5fab2c --- /dev/null +++ b/SLICE1_DEMO.md @@ -0,0 +1,28 @@ +# Slice 1 Acceptance Demo — verbatim script + +This is the acceptance test for Slice 1 (PRD_ADDENDUM §6). When every step below works end-to-end, Slice 1 ships. Nothing outside this script is in scope. + +## Cast + +- Company: **GasWatch Inc.** (first real subsidiary; brief: track local gas prices, tiny web app) +- Employees: `Mara` (Manager), `Dev` (Engineer), `Quinn` (QA) — three Hermes profiles, qwen3.5:9b + +## Script + +1. `POST /companies` with the GasWatch brief → company row, event `company.created`, three employees hired (`employee.hired` ×3), Hermes profiles created via adapter. +2. Engine asks Mara to decompose the brief → Mara returns plan; engine creates ~3 work items (`work_item.created`), one flagged as needing clarification. +3. Clarification flows: Mara emits `clarification.requested` with 2–3 options + recommendation → surfaces in dashboard → user answers → `clarification.answered` → dependent task unblocks. Non-blocked tasks proceeded meanwhile. +4. Dev picks up the feature task: engine dispatches to Hermes (`dispatch.sent`), worktree created on an isolated branch, code committed. Every agent report lands as validated `AgentMessage` events. +5. Quinn runs pytest + one Playwright browser flow against the running app; screenshots + trace captured (`test.started` / `test.completed`, `artifact.created`). +6. Seeded defect found by Quinn → defect work item → Dev fixes → Quinn re-verifies (defect loop closed). +7. Evidence bundle assembled (commits, test output, screenshots, trace) → `artifact.created(kind=evidence_bundle)` → downloadable from dashboard. +8. Dashboard (read-only) showed live truthful states the whole time: employee status, work item board, event feed, cost tally (`cost.recorded` per run). +9. Runaway guard demonstrated: one task configured with `max_iterations: 2` trips `run.capped` and blocks cleanly instead of looping. +10. Global pause: `POST /pause` mid-run → no new dispatches, in-flight inference drains, tool executions abort, state consistent, resume works. + +## Hard exit criteria + +- Zero free-form parsing: every agent→engine message validated against `AgentMessage`. +- Event log replay tells the whole story; no state exists that lacks a causal event. +- `task.blocked` from any agent was triaged by Mara before surfacing to user. +- Kill Hermes mid-demo → engine marks run failed, work item re-dispatchable, no corruption. diff --git a/adr/ADR-003-event-schema-and-data-model.md b/adr/ADR-003-event-schema-and-data-model.md new file mode 100644 index 0000000..f1063b8 --- /dev/null +++ b/adr/ADR-003-event-schema-and-data-model.md @@ -0,0 +1,26 @@ +# ADR-003: Event Schema and Core Data Model + +**Status:** Accepted +**Date:** 2026-08-03 + +## Decision + +Sources of truth: + +- `db/schema.sql` — DDL for the Slice 1 entity subset: company, employee, work_item, event, approval, artifact, tool_execution, cost_entry, policy_rule. Remaining PRD §14.3 entities (Schedule, KnowledgeItem, Incident, ConsultantEngagement, …) are added by the slice that needs them, not up front. +- `engine/schemas/events.py` — pydantic models. `AgentMessage` (agent→engine, enforced at inference via structured output) and `EventEnvelope` (immutable log record). The pydantic models ARE the schema; no parallel JSON-schema files to drift. + +Key choices: + +1. **Append-only event log in Postgres** with a trigger rejecting UPDATE/DELETE. Projections (work_item.status, employee.status) are ordinary tables updated in the same transaction as the event insert. No event-sourcing framework. +2. **Flat agent message schema.** One shape for all 12 agent message types, optional fields per type. Benchmarked at 100% validity on qwen3.5:9b under `format` enforcement; nested per-type schemas degrade small models and multiply retry logic. +3. **`task.blocked` is triage** (benchmark finding: model confuses blocked vs clarification vs approval). Engine manager step reclassifies before user-facing routing. +4. **Deny-by-default policy table.** `policy_rule` rows grant max authority per (company, employee, tool-glob, environment); most specific enabled rule wins; no matching rule = denied. Seeded globals: sandbox=2, internal=1, external/production=0. Every tool execution stores the decision it ran under. +5. **Runaway caps live in `work_item.budget`** (max_iterations 15, max_tokens 100k, max_wall_seconds 1800 default) and are engine-enforced; `run.capped` event fires when tripped (PRD_ADDENDUM §3). +6. **UUIDs everywhere, UTC timestamptz everywhere.** Display timezone (`America/Indiana/Indianapolis`) is a UI concern only. + +## Rejected + +- Kafka/NATS event bus — Postgres insert + Redis pub/sub notify covers 4-worker scale. +- Full event-sourcing (rebuild state from log) — projections are authoritative for current state; the log is authoritative for history and audit. +- Per-company database — schema-shared, row-scoped by company_id; revisit if isolation demands grow. diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..871593b --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,161 @@ +-- FarmHouse Holdings — core schema v1 (Slice 1 subset, PRD §14.3) +-- Postgres 15+. Deferred entities (Schedule, KnowledgeItem, Incident, ...) added when their slice needs them. + +CREATE TABLE company ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + slug text NOT NULL UNIQUE, + name text NOT NULL, + description text NOT NULL DEFAULT '', + lifecycle_state text NOT NULL DEFAULT 'draft' + CHECK (lifecycle_state IN ('draft','active','paused','archived')), + -- repo, discord channel, office theme, autonomy policy, tz override + config jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE employee ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id uuid NOT NULL REFERENCES company(id), + name text NOT NULL, + title text NOT NULL, + department text NOT NULL DEFAULT '', + manager_id uuid REFERENCES employee(id), + hermes_profile text, -- Hermes profile name (ADR-001) + model_alias text NOT NULL DEFAULT 'local-default', + authority_level int NOT NULL DEFAULT 2 CHECK (authority_level BETWEEN 0 AND 5), + soul_version int NOT NULL DEFAULT 1, -- versioned system prompt (PRD §6.6) + status text NOT NULL DEFAULT 'idle' + CHECK (status IN ('idle','working','blocked','offline')), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (company_id, name) +); + +CREATE TABLE work_item ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id uuid NOT NULL REFERENCES company(id), + parent_id uuid REFERENCES work_item(id), + type text NOT NULL CHECK (type IN + ('objective','milestone','epic','task','subtask','defect', + 'clarification','approval','research','proposal','experiment', + 'review','incident','postmortem')), + title text NOT NULL, + description text NOT NULL DEFAULT '', + status text NOT NULL DEFAULT 'backlog' CHECK (status IN + ('backlog','ready','in_progress','blocked','review','done','cancelled')), + priority text NOT NULL DEFAULT 'medium' CHECK (priority IN + ('critical','high','medium','low','debt','research','experiment','idea')), + owner_id uuid REFERENCES employee(id), + acceptance_criteria jsonb NOT NULL DEFAULT '[]', + -- runaway caps, engine-enforced (PRD_ADDENDUM §3): max_iterations, max_tokens, max_wall_seconds + budget jsonb NOT NULL DEFAULT '{"max_iterations":15,"max_tokens":100000,"max_wall_seconds":1800}', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX work_item_dispatch_idx ON work_item (company_id, status, priority); + +-- Append-only event log: single source of truth for history (PRD §14.2). +CREATE TABLE event ( + event_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id uuid REFERENCES company(id), -- NULL = holdings-level + actor text NOT NULL, -- 'employee:' | 'user:philip' | 'system' + event_type text NOT NULL, -- see engine/schemas/events.py + schema_version int NOT NULL DEFAULT 1, + work_item_id uuid REFERENCES work_item(id), + parent_event_id uuid REFERENCES event(event_id), + correlation_id uuid, -- groups one logical exchange + source text NOT NULL DEFAULT 'engine' + CHECK (source IN ('engine','web','api','discord','hermes','scheduler','cli')), + payload jsonb NOT NULL DEFAULT '{}', + policy_decision jsonb, -- {allowed, rule_id, level, reason} + provenance jsonb, -- {model, worker, tool, hermes_run_id} + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX event_company_time_idx ON event (company_id, created_at); +CREATE INDEX event_work_item_idx ON event (work_item_id) WHERE work_item_id IS NOT NULL; + +CREATE FUNCTION event_immutable() RETURNS trigger AS $$ +BEGIN RAISE EXCEPTION 'event log is append-only'; END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER event_no_update BEFORE UPDATE OR DELETE ON event + FOR EACH ROW EXECUTE FUNCTION event_immutable(); + +CREATE TABLE approval ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id uuid NOT NULL REFERENCES company(id), + work_item_id uuid REFERENCES work_item(id), + requested_by uuid NOT NULL REFERENCES employee(id), + level int NOT NULL CHECK (level BETWEEN 3 AND 5), + summary text NOT NULL, + options jsonb NOT NULL DEFAULT '[]', -- clarification options (PRD §7.6) + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','approved','rejected','expired')), + decided_by text, -- 'user:philip' | 'policy:' + rationale text, + created_at timestamptz NOT NULL DEFAULT now(), + decided_at timestamptz +); + +CREATE TABLE artifact ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id uuid NOT NULL REFERENCES company(id), + work_item_id uuid REFERENCES work_item(id), + kind text NOT NULL CHECK (kind IN + ('evidence_bundle','screenshot','video','trace','test_report', + 'build','preview','document','diff')), + uri text NOT NULL, -- file path or object-store URI + meta jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE tool_execution ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id uuid NOT NULL REFERENCES company(id), + employee_id uuid NOT NULL REFERENCES employee(id), + work_item_id uuid REFERENCES work_item(id), + tool text NOT NULL, + args jsonb NOT NULL DEFAULT '{}', + authority_level int NOT NULL, + policy_decision jsonb NOT NULL, -- always recorded (PRD §16.1) + status text NOT NULL DEFAULT 'running' + CHECK (status IN ('running','ok','error','denied','timeout')), + result_summary text, + started_at timestamptz NOT NULL DEFAULT now(), + finished_at timestamptz +); + +CREATE TABLE cost_entry ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id uuid NOT NULL REFERENCES company(id), + employee_id uuid REFERENCES employee(id), + work_item_id uuid REFERENCES work_item(id), + kind text NOT NULL CHECK (kind IN ('local_inference','consultant','tool','storage')), + tokens_in bigint NOT NULL DEFAULT 0, + tokens_out bigint NOT NULL DEFAULT 0, + wall_seconds numeric NOT NULL DEFAULT 0, + usd numeric NOT NULL DEFAULT 0, -- 0 for local; real for consultants + meta jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX cost_company_time_idx ON cost_entry (company_id, created_at); + +-- Deterministic policy: most specific enabled rule wins (company+employee > company > global). +-- Engine denies by default when no rule matches (PRD §8: enforced in code, not prompts). +CREATE TABLE policy_rule ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + company_id uuid REFERENCES company(id), -- NULL = global + employee_id uuid REFERENCES employee(id), -- NULL = any employee + tool_pattern text NOT NULL DEFAULT '*', -- glob: 'git.*', 'shell', '*' + environment text NOT NULL DEFAULT 'sandbox' + CHECK (environment IN ('sandbox','internal','external','production')), + max_level int NOT NULL CHECK (max_level BETWEEN 0 AND 5), + enabled boolean NOT NULL DEFAULT true, + note text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Global defaults: read/propose/sandbox anywhere; nothing above level 2 without an explicit rule. +INSERT INTO policy_rule (tool_pattern, environment, max_level, note) VALUES + ('*', 'sandbox', 2, 'global default: full sandbox freedom'), + ('*', 'internal', 1, 'global default: propose-only against internal env'), + ('*', 'external', 0, 'global default: observe-only external'), + ('*', 'production', 0, 'global default: observe-only production'); diff --git a/engine/schemas/events.py b/engine/schemas/events.py new file mode 100644 index 0000000..8ed6a70 --- /dev/null +++ b/engine/schemas/events.py @@ -0,0 +1,108 @@ +"""Event schema v1 — the versioned contract between agents, engine, and interfaces. + +Two layers: + * AgentMessage — what an employee (Hermes run) reports back. Small, flat, + enforced at inference time via Ollama/Hermes structured output + (qwen3.5:9b scored 100% validity on this shape; see benchmarks/RESULTS.md). + * EventEnvelope — what the engine appends to the immutable event log after + policy evaluation. Superset of agent messages: engine/system events too. + +Rule from benchmarks: `task.blocked` from an agent is a TRIAGE state. The +engine (manager step) reclassifies to clarification.requested / +approval.requested before anything reaches the user. +""" +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, Field + +SCHEMA_VERSION = 1 + +# ---------------------------------------------------------------- agent -> engine + +AgentMessageType = Literal[ + "task.accepted", + "task.progressed", + "task.blocked", + "task.completed", + "clarification.requested", + "approval.requested", + "artifact.created", + "test.started", + "test.completed", + "review.completed", + "consultant.requested", + "incident.declared", +] + + +class AgentMessage(BaseModel): + """Kept flat on purpose — 9B models degrade on nested schemas (PRD_ADDENDUM §2).""" + + message_type: AgentMessageType + work_item_id: str + summary: str = Field(max_length=500) + detail: str = "" + # clarification/approval: 2-3 options with a recommendation (PRD §7.6) + options: list[str] = [] + recommended_option: str = "" + # artifact.created / test.completed + artifact_uri: str = "" + tests_passed: int | None = None + tests_failed: int | None = None + + +# JSON Schema handed to Hermes/Ollama `format` for enforcement at inference time. +AGENT_MESSAGE_FORMAT: dict = AgentMessage.model_json_schema() + +# ---------------------------------------------------------------- engine event log + +EngineEventType = Literal[ + # lifecycle + "company.created", "company.paused", "company.resumed", "company.archived", + "employee.hired", "employee.updated", + "work_item.created", "work_item.updated", "work_item.assigned", + # dispatch loop + "dispatch.sent", "dispatch.failed", "run.capped", # run.capped = runaway guard fired + # decisions + "clarification.answered", "approval.decided", "policy.denied", + # accounting + "cost.recorded", +] + +EventType = AgentMessageType | EngineEventType + +Source = Literal["engine", "web", "api", "discord", "hermes", "scheduler", "cli"] + + +class PolicyDecision(BaseModel): + allowed: bool + rule_id: UUID | None = None + level: int + reason: str = "" + + +class Provenance(BaseModel): + model: str = "" + worker: str = "" + tool: str = "" + hermes_run_id: str = "" + + +class EventEnvelope(BaseModel): + """Mirrors db/schema.sql `event` table. Append-only; never mutated.""" + + event_id: UUID + company_id: UUID | None = None + actor: str # 'employee:' | 'user:philip' | 'system' + event_type: EventType + schema_version: int = SCHEMA_VERSION + work_item_id: UUID | None = None + parent_event_id: UUID | None = None + correlation_id: UUID | None = None + source: Source = "engine" + payload: dict = {} + policy_decision: PolicyDecision | None = None + provenance: Provenance | None = None + created_at: datetime