Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ APPROVAL_TTL_SECONDS=3600
# REQUIRED outside Docker Compose.
DATABASE_URL=postgresql+asyncpg://gdev_app:gdev-app-pass@localhost:5432/gdev
REDIS_URL=redis://localhost:6379
# Local Compose-only database role passwords. Replace them in shared environments.
GDEV_OWNER_PASSWORD=gdev-owner-pass
GDEV_APP_PASSWORD=gdev-app-pass

# Output guard
OUTPUT_GUARD_ENABLED=true
Expand Down
57 changes: 52 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,26 @@ jobs:
- 6379:6379

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.10"
python-version: "3.12"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements-dev.txt -e .

- name: Lint (ruff)
run: ruff check app/ tests/ scripts/ eval/
run: >-
ruff check app/ tests/ scripts/ eval/
alembic/versions/0007_enforce_compose_rls_topology.py

- name: Format check (ruff format)
run: ruff format --check app/ tests/ scripts/ eval/
run: >-
ruff format --check app/ tests/ scripts/ eval/
alembic/versions/0007_enforce_compose_rls_topology.py

- name: Run tests
env:
Expand All @@ -61,9 +65,52 @@ jobs:
TEST_DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/gdev_test
REDIS_URL: redis://localhost:6379
JWT_SECRET: test-secret-at-least-32-characters-long
LLM_MODE: demo
PYTHONDONTWRITEBYTECODE: "1"
run: python -m pytest tests/ -q --tb=short

- name: Eval regression gate
env:
LLM_MODE: demo
run: python -m eval.runner --gate --no-write

compose-isolation:
name: Compose RLS + Demo
runs-on: ubuntu-latest
env:
GDEV_OWNER_PASSWORD: ci-gdev-owner-password
GDEV_APP_PASSWORD: ci-gdev-app-password
LLM_MODE: demo

steps:
- uses: actions/checkout@v5

- name: Start default application topology
run: docker compose up --build -d postgres redis migrate agent

- name: Prove role topology and tenant isolation
run: bash scripts/verify_compose_rls.sh

- name: Wait for application health
run: |
agent_id="$(docker compose ps -q agent)"
for _ in {1..30}; do
if [ "$(docker inspect -f '{{.State.Health.Status}}' "$agent_id")" = "healthy" ]; then
exit 0
fi
sleep 2
done
exit 1

- name: Run deterministic login and approval demo
run: >-
docker compose exec -T agent
python scripts/demo.py --url http://localhost:8000 --llm-mode demo

- name: Show service logs on failure
if: failure()
run: docker compose logs --no-color postgres migrate agent

- name: Stop stack
if: always()
run: docker compose down --volumes --remove-orphans
35 changes: 24 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# gdev-agent

![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-3776AB?logo=python&logoColor=white) ![FastAPI](https://img.shields.io/badge/fastapi-api-009688?logo=fastapi&logoColor=white) ![Postgres](https://img.shields.io/badge/postgres-pgvector-4169E1?logo=postgresql&logoColor=white) ![Docker Compose](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![285 tests](https://img.shields.io/badge/tests-285%20passing-brightgreen)
![Python 3.12](https://img.shields.io/badge/python-3.12-3776AB?logo=python&logoColor=white) ![FastAPI](https://img.shields.io/badge/fastapi-api-009688?logo=fastapi&logoColor=white) ![Postgres](https://img.shields.io/badge/postgres-pgvector-4169E1?logo=postgresql&logoColor=white) ![Docker Compose](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white)

`gdev-agent` is a governed, multi-tenant LLM workflow reliability system for
game-studio support: it receives support webhooks, blocks unsafe input before
Expand All @@ -26,11 +26,11 @@ For a claim-by-claim proof map, start with
| Architecture and workflow boundaries | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), [docs/architecture-diagram.md](docs/architecture-diagram.md) | Implemented local stack with documented gaps and ADRs |
| Agent harness boundary | [docs/HARNESS_CARD.md](docs/HARNESS_CARD.md), [docs/TRACE_SCHEMA.md](docs/TRACE_SCHEMA.md), [AGENTS.md](AGENTS.md) | Model + prompt/tool loop + guards + approvals + trace + eval are reviewed as one bounded harness |
| Repeatable demo path | [docs/DEMO.md](docs/DEMO.md) | Local Compose demo with deterministic/free mode |
| Evaluation discipline | [docs/EVALUATION.md](docs/EVALUATION.md), [docs/EVAL_REPORT.md](docs/EVAL_REPORT.md), [docs/EVAL_SCOPE_RECONCILIATION.md](docs/EVAL_SCOPE_RECONCILIATION.md) | 180-case internal smoke eval, 55-case Eval Lab integration baseline, and scope reconciliation |
| Evaluation discipline | [docs/EVALUATION.md](docs/EVALUATION.md), [docs/EVAL_REPORT.md](docs/EVAL_REPORT.md), [docs/EVAL_SCOPE_RECONCILIATION.md](docs/EVAL_SCOPE_RECONCILIATION.md) | 180-case internal smoke eval, 55-case Eval Lab conformance baseline, and a separately identified unexecuted 100-case challenge scope |
| Observability | [docs/observability.md](docs/observability.md) | Metrics, traces, logs, and alerting design for local evidence |
| Load profile | [docs/load-profile.md](docs/load-profile.md), [docs/LOAD_TEST_REPORT.md](docs/LOAD_TEST_REPORT.md) | Local deterministic/synthetic report and scenario targets; not production capacity claims |
| Tenant isolation and security | [docs/TENANT_ISOLATION.md](docs/TENANT_ISOLATION.md), [docs/data-map.md#6-tenant-isolation-model](docs/data-map.md#6-tenant-isolation-model), [docs/ARCHITECTURE.md#7-security-model](docs/ARCHITECTURE.md#7-security-model) | RLS, tenant-scoped JWT, webhook signature, secrets, approval, and cost ledger boundaries |
| Tests | [Current State](#current-state) | Last recorded baseline is 285 passing tests; rerun locally before relying on it |
| Tests | [Current State](#current-state) | 2026-07-13 local baseline: 310 passing tests; rerun locally before relying on it |
| Failure modes and SLO/runbook | [docs/FAILURE_MODES.md](docs/FAILURE_MODES.md), [docs/SLO_RUNBOOK.md](docs/SLO_RUNBOOK.md), [docs/observability.md#alert-runbooks](docs/observability.md#alert-runbooks) | Local taxonomy and runbook evidence; external incident evidence is out of scope |
| Deployment readiness boundaries | [docs/DEPLOYMENT_READINESS.md](docs/DEPLOYMENT_READINESS.md), [Known Limits](#known-limits) | Secrets checklist, backup/restore notes, local production-like config, and known limitations without production readiness claims |
| Known limits and production changes | [Known Limits](#known-limits), [docs/DEPLOYMENT_READINESS.md](docs/DEPLOYMENT_READINESS.md) | Explicitly bounded as pilot/local evidence, not production SaaS readiness |
Expand Down Expand Up @@ -66,11 +66,11 @@ The current stack includes FastAPI, Redis, PostgreSQL with Row-Level Security, p
| AI pipeline | Claude `tool_use` classification and extraction, guarded draft generation, configurable auto-approve threshold |
| Safety | Input injection guard, output secret scan, URL allowlist enforcement, approval workflow with `ApprovalService` (HMAC + cross-tenant enforcement) |
| Execution | Tool registry for ticketing and reply actions, dedup cache for idempotent replays, pending approval storage with TTL |
| Multi-tenancy | PostgreSQL RLS on all tables (Alembic migrations), tenant registry, per-tenant encrypted secrets |
| Multi-tenancy | PostgreSQL FORCE RLS on all 16 tenant-scoped tables, non-owner `gdev_app` runtime role, tenant registry, per-tenant encrypted secrets |
| Operations | Cost ledger with daily budget enforcement, structured JSON logs, Prometheus metrics (OTel child spans on all endpoints), Grafana/Loki/Tempo stack |
| Analytics | Eval runner with budget check, eval API, tenant learning metrics from approval latency/overrides, RCA clustering job (DBSCAN + pgvector), cluster read endpoints with DB-backed membership |
| Admin | `gdev-admin` CLI for tenant/budget/RCA operations, admin role with BYPASSRLS |
| Platform | Docker Compose full stack; 285 tests (unit + integration) passing; ruff-clean |
| Admin | `gdev-admin` CLI for tenant/budget/RCA operations; separate maintenance role with BYPASSRLS |
| Platform | Docker Compose full stack; Python 3.12 CI; ruff, full pytest, eval, Compose RLS, and deterministic demo gates |

## Quick Start

Expand Down Expand Up @@ -98,12 +98,17 @@ What starts:
| tempo | `http://localhost:3200` | Trace backend |
| loki | `http://localhost:3100` | Log backend |

The `migrate` service runs Alembic and seeds the database before the API starts. In the compose stack, `DATABASE_URL`, `REDIS_URL`, `JWT_SECRET`, `APPROVE_SECRET`, `WEBHOOK_SECRET_ENCRYPTION_KEY`, and `OTLP_ENDPOINT` are injected automatically. `LLM_MODE` defaults to deterministic `demo` mode.
The `migrate` service runs Alembic and seeds the database before the API starts.
Compose creates a bootstrap/migration owner (`gdev_owner`) and a distinct
non-superuser request role (`gdev_app`). Their local-only passwords come from
`GDEV_OWNER_PASSWORD` and `GDEV_APP_PASSWORD` in the copied `.env`; Compose has
no password fallback. `LLM_MODE` defaults to deterministic `demo` mode.

Verify the stack:

```bash
curl -i http://localhost:8000/health
bash scripts/verify_compose_rls.sh
```

Expected response:
Expand Down Expand Up @@ -131,6 +136,7 @@ Copy [.env.example](.env.example) and adjust only what you need for your environ
| `KB_BASE_URL` | Recommended | FAQ links should also be present in `URL_ALLOWLIST` |
| `REDIS_URL` | Yes | Approval store, rate limiting, dedup, caching |
| `DATABASE_URL` | Yes for Postgres features | Compose provides it automatically |
| `GDEV_OWNER_PASSWORD` / `GDEV_APP_PASSWORD` | Yes for Compose | Separate migration-owner and request-role passwords; local examples are in `.env.example` |
| `TEST_DATABASE_URL` | No | Test-only override |
| `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` | No | Async Postgres pool sizing |
| `WEBHOOK_SECRET` | Optional legacy path | Global webhook secret; per-tenant secret storage is the main design |
Expand Down Expand Up @@ -167,7 +173,7 @@ Copy [.env.example](.env.example) and adjust only what you need for your environ

| Endpoint | Purpose |
| --- | --- |
| `POST /webhook` | Main ingestion path for support messages; returns either `executed` or `pending` |
| `POST /webhook` | Main ingestion path for support messages; returns `executed`, `pending`, or guard-`blocked` |
| `POST /approve` | Human decision endpoint for pending actions |
| `GET /health` | Application liveness check used by Docker health checks |
| `GET /metrics` | Prometheus scrape endpoint |
Expand Down Expand Up @@ -211,7 +217,7 @@ Most endpoints outside `/health`, `/webhook`, and `/metrics` require JWT auth pl

- [docs/EVIDENCE_INDEX.md](docs/EVIDENCE_INDEX.md): evidence question map and claim-by-claim proof table.
- [docs/STACK_OVERVIEW.md](docs/STACK_OVERVIEW.md): three-project stack map and provider strategy.
- [docs/EVAL_SCOPE_RECONCILIATION.md](docs/EVAL_SCOPE_RECONCILIATION.md): explains the internal 180-case smoke eval versus the Eval Lab 55-case integration baseline.
- [docs/EVAL_SCOPE_RECONCILIATION.md](docs/EVAL_SCOPE_RECONCILIATION.md): reconciles the internal 180-case smoke, Eval Lab 55-case conformance baseline, unexecuted 100-case challenge scope, and Runtime Grid proofs.
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): system structure, service boundaries, request flow, deployment view.
- [docs/HARNESS_CARD.md](docs/HARNESS_CARD.md): agent harness boundary across model, tools, memory, retries, permissions, HITL, trace, and eval.
- [docs/TRACE_SCHEMA.md](docs/TRACE_SCHEMA.md): trace completeness contract for debugging, eval, audit, and approval retrospectives.
Expand Down Expand Up @@ -239,7 +245,8 @@ Most endpoints outside `/health`, `/webhook`, and `/metrics` require JWT auth pl
live capacity proof.
- Eval metrics have multiple scopes. The internal 180-case smoke report exposes
broad demo-mode routing gaps, while the external Eval Lab 55-case baseline is
an integration/conformance pass over the configured `/webhook` adapter. See
an integration/conformance pass over the configured `/webhook` adapter. The
separate 100-case challenge dataset has no canonical executed run yet. See
[docs/EVAL_SCOPE_RECONCILIATION.md](docs/EVAL_SCOPE_RECONCILIATION.md).
- Live load measurements remain out of scope for the current local evidence.
Deployment readiness notes are local/pilot-only and explicitly do not prove
Expand All @@ -258,6 +265,12 @@ with read-route extraction still tracked as architecture drift, Dockerized
observability, admin CLI, and the n8n workflow artifacts needed for demo or
pilot-style setups.

**285 tests pass** (unit + integration, including RLS isolation, migration up/down, cross-tenant rejection, eval metric validators, reliability boundary tests, load fixture validation, observability signal checks, and cluster membership persistence). All P0 and P1 findings from 18 review cycles have been resolved.
The 2026-07-13 local baseline is **310 tests passed** (unit + integration,
including migration up/down, role flags, FORCE RLS topology, cross-tenant
rejection, eval metric validators, load fixtures, observability signals, and
cluster membership persistence). The same repair run also passed the 180-case
demo eval gate and a clean Compose auth/approval demo. Exact commands and
bounded outputs are recorded in
[docs/evidence/GDEV_P0_TRUTH_REPAIR_2026-07-13.md](docs/evidence/GDEV_P0_TRUTH_REPAIR_2026-07-13.md).

The main value is the governed request pipeline: webhook in → guardrails → LLM-assisted triage → human approval where needed → auditable execution throughout, with tenant isolation enforced at the database layer and observable at every step.
60 changes: 60 additions & 0 deletions alembic/versions/0007_enforce_compose_rls_topology.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Enforce non-owner application role and forced tenant RLS."""

from __future__ import annotations

from typing import Sequence, Union

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "0007"
down_revision: Union[str, Sequence[str], None] = "0006"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None

TENANT_SCOPED_TABLES = (
"tenant_users",
"api_keys",
"webhook_secrets",
"tickets",
"ticket_classifications",
"ticket_extracted_fields",
"proposed_actions",
"pending_decisions",
"approval_events",
"audit_log",
"ticket_embeddings",
"cluster_summaries",
"rca_cluster_members",
"agent_configs",
"cost_ledger",
"eval_runs",
)


def upgrade() -> None:
# Existing clusters may already have a gdev_app role created by the original
# migration. Normalize its capabilities without changing its password.
op.execute(
"""
ALTER ROLE gdev_app
LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE
NOINHERIT NOREPLICATION NOBYPASSRLS
"""
)
op.execute("GRANT USAGE ON SCHEMA public TO gdev_app")
op.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO gdev_app")
op.execute("GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO gdev_app")

for table_name in TENANT_SCOPED_TABLES:
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY")


def downgrade() -> None:
# Do not restore superuser/BYPASSRLS privileges during downgrade. Reversing
# those security properties would be an unsafe and surprising side effect.
for table_name in reversed(TENANT_SCOPED_TABLES):
op.execute(f"ALTER TABLE IF EXISTS {table_name} NO FORCE ROW LEVEL SECURITY")
op.execute("REVOKE USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public FROM gdev_app")
op.execute("REVOKE USAGE ON SCHEMA public FROM gdev_app")
3 changes: 1 addition & 2 deletions app/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,8 +312,7 @@ def process_webhook(
"predicted_urgency": classification.urgency,
"reason": exemplar_consistency.reason,
"matches": [
match.model_dump(mode="json")
for match in exemplar_consistency.matches
match.model_dump(mode="json") for match in exemplar_consistency.matches
],
},
)
Expand Down
4 changes: 1 addition & 3 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,7 @@ class Settings(BaseSettings):
exemplar_guard_threshold: float = 0.62
exemplar_guard_top_k: int = 3
exemplar_guard_examples_path: str | None = None
approval_categories: Annotated[list[str], NoDecode] = Field(
default_factory=lambda: ["billing"]
)
approval_categories: Annotated[list[str], NoDecode] = Field(default_factory=lambda: ["billing"])
approval_ttl_seconds: int = 3600
sqlite_log_path: str | None = None
redis_url: str = "redis://redis:6379"
Expand Down
17 changes: 13 additions & 4 deletions app/exemplar_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
)

LOGGER = logging.getLogger(__name__)
DEFAULT_EXEMPLAR_PATH = Path(__file__).resolve().parents[1] / "eval" / "exemplars" / "triage_v1.jsonl"
DEFAULT_EXEMPLAR_PATH = (
Path(__file__).resolve().parents[1] / "eval" / "exemplars" / "triage_v1.jsonl"
)
WORD_RE = re.compile(r"[a-z0-9]+")
VALID_CATEGORIES = set(get_args(Category))

Expand Down Expand Up @@ -90,11 +92,15 @@ class TriageExemplar:
class ExemplarConsistencyGuard:
"""Compare a new triage decision against curated synthetic examples."""

def __init__(self, settings: Settings, exemplars: Iterable[TriageExemplar] | None = None) -> None:
def __init__(
self, settings: Settings, exemplars: Iterable[TriageExemplar] | None = None
) -> None:
self.enabled = settings.exemplar_guard_enabled
self.threshold = settings.exemplar_guard_threshold
self.top_k = max(1, settings.exemplar_guard_top_k)
self.exemplars = tuple(exemplars) if exemplars is not None else self._load_exemplars(settings)
self.exemplars = (
tuple(exemplars) if exemplars is not None else self._load_exemplars(settings)
)

def evaluate(
self,
Expand Down Expand Up @@ -173,7 +179,10 @@ def _load_exemplars(self, settings: Settings) -> tuple[TriageExemplar, ...]:
LOGGER.warning(
"failed loading exemplar guard examples",
exc_info=True,
extra={"event": "exemplar_guard_examples_load_failed", "context": {"path": str(path)}},
extra={
"event": "exemplar_guard_examples_load_failed",
"context": {"path": str(path)},
},
)
return DEFAULT_EXEMPLARS

Expand Down
Loading