# Technical Documentation: potpie-ai/potpie

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/potpie-ai/potpie)

### Overview (`README.md`)

<p align="center">
  <a href="https://potpie.ai?utm_source=github">
    <img src="https://raw.githubusercontent.com/potpie-ai/potpie/main/assets/readme_logo_light.svg" alt="Potpie AI logo" />
  </a>
</p>

<p align="center">
  <a href="https://docs.potpie.ai"><img src="https://img.shields.io/badge/Docs-potpie.ai-111827?style=for-the-badge&logo=readthedocs&logoColor=white&labelColor=22c55e" alt="Docs"></a>
  <a href="https://github.com/potpie-ai/potpie/actions/workflows/test.yml"><img src="https://img.shields.io/github/actions/workflow/status/potpie-ai/potpie/test.yml?branch=main&style=for-the-badge&label=Tests&logo=githubactions&logoColor=white&labelColor=111827" alt="Tests"></a>
  <a href="https://pypi.org/project/potpie/"><img src="https://img.shields.io/pypi/v/potpie?style=for-the-badge&label=PyPI&logo=pypi&logoColor=white&labelColor=111827&color=3775A9" alt="PyPI"></a>
  <a href="https://discord.gg/ryk5CMD5v6"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white&labelColor=111827" alt="Discord"></a>
  <a href="https://github.com/potpie-ai/potpie/blob/main/LICENSE"><img src="https://img.shields.io/github/license/potpie-ai/potpie?style=for-the-badge&label=License&color=64748b&labelColor=111827" alt="Apache 2.0"></a>
</p>

[Potpie](https://potpie.ai) turns your codebase and software development lifecycle into a living context graph for AI agents.
It indexes code, structure, decisions, source history, team knowledge and engineering workflows, so agents can answer questions, plan changes, debug failures, and write code with project-specific context.



## Install and setup Potpie

### Step 1: install the CLI through [PyPI](https://pypi.org/project/potpie/) with `uv` or `pip`:

```bash
uv tool install potpie
```

or:

```bash
python3 -m pip install --user potpie
```

> [!NOTE]
> `uv tool install potpie` is recommended for CLI installs because global
> mutation of Python packages is generally not recommended.

### Step 2: Run the Potpie setup wizard

```bash
potpie setup
```

The setup wizard provisions local config, storage, the daemon, a default pot, and
agent skills. It also lets you choose integrations and the coding harness Potpie
should configure.



### Step 3: Open your configured harness

Potpie is already integrated into your selected harness.
You can start using Potpie with the repo of your choice.

Open your previously selected harness and ask it to use Potpie for the repo.



> [!NOTE]
> You don't need to run a separate manual ingest command. The CLI registers
> sources and the configured agent can ingest or update project context when the
> task requires it.

You can view your context graph in the web UI:

```bash
potpie ui
```

This will open a graph explorer in your browser.


## Basic CLI user checklist

The main CLI commands are:

| Command | Purpose |
| --- | --- |
| `potpie setup` | Run first-time local setup for config, daemon, default pot, and agent skills. |
| `potpie login` | Sign in to Potpie for account-backed and managed features. |
| `potpie github login` / `potpie linear login` | Connect source integrations you want agents to use. |
| `potpie status` | Show context readiness for the active pot, including daemon, graph, and skill checks. |
| `potpie auth status` | Show configured integration auth status. |
| `potpie auth status --verify` | Verify integration credentials with lightweight API checks. |
| `potpie doctor` | Run local diagnostics for daemon, backend capabilities, and skill drift. |
| `potpie source add repo .` | Register the current repo as a source for the resolved pot. |
| `potpie pot list` / `potpie pot use <id-or-name>` | List pots and choose the active workspace. |
| `potpie resolve "<task>"` | Pull the context an agent should read before doing a task. |
| `potpie search "<query>"` | Look up a specific file, workflow, bug, decision, or convention. |
| `potpie record --type <type> --summary "<summary>"` | Write a durable project learning. |
| `potpie graph ...` | Use lower-level graph reads, quality checks, proposals, and commits. |
| `potpie ui` | Open the local graph explorer served by the daemon. |
| `potpie skills install --agent <agent>` | Install or refresh Potpie guidance for an agent harness. |

Examples:

```bash
potpie setup --repo . --agent claude
potpie github login
potpie auth status
potpie status
potpie source add repo .
potpie resolve "what should I know before working in this repository?"
potpie search "authentication flow"
potpie record --type decision --summary "Prefer the Potpie CLI for graph work"
```

You can find an exhaustive list with more examples in our [docs](https://docs.potpie.ai).

## Integrations and Coding Harnesses

Potpie supports a variety of integrations and coding harnesses, with more coming.
If your team needs a new integration or harness, please
[raise a ticket](https://github.com/potpie-ai/potpie/issues/new/choose).

### Integrations

| Tool | Description |
| --- | --- |
| ![GitHub][github-badge] | Index repositories, pull requests, issues, reviews and source history. |
| ![Linear][linear-badge] | Index teams, issues, projects and documents. |
| ![Jira][jira-badge] | Index projects, issues, status and changelog context. |
| ![Confluence][confluence-badge] | Index spaces, pages, runbooks and decisions. |

### Coding harnesses

| Tool | Description |
| --- | --- |
| ![Claude Code][claude-badge] | Install Potpie instructions and skills for Claude Code. |
| ![OpenAI Codex][codex-badge] | Install Potpie instructions and skills for OpenAI Codex. |
| ![Cursor][cursor-badge] | Install Potpie instructions and skills for Cursor. |
| ![OpenCode][opencode-badge] | Install Potpie skills for OpenCode. |

[github-badge]: https://img.shields.io/badge/GitHub-111827?style=flat-square&logo=github&logoColor=white
[linear-badge]: https://img.shields.io/badge/Linear-111827?style=flat-square&logo=linear&logoColor=white
[jira-badge]: https://img.shields.io/badge/Jira-111827?style=flat-square&logo=jira&logoColor=white
[confluence-badge]: https://img.shields.io/badge/Confluence-111827?style=flat-square&logo=confluence&logoColor=white
[claude-badge]: https://img.shields.io/badge/Claude%20Code-111827?style=flat-square&logo=anthropic&logoColor=white
[codex-badge]: https://img.shields.io/badge/OpenAI%20Codex-111827?style=flat-square&logo=openai&logoColor=white
[cursor-badge]: https://img.shields.io/badge/Cursor-111827?style=flat-square&logo=cursor&logoColor=white
[opencode-badge]: https://img.shields.io/badge/OpenCode-111827?style=flat-square&logo=opencode&logoColor=white

## Architecture

Potpie's current architecture is CLI-first. CLI is designed to be used by both humans and agents. Read the deeper architecture notes in [`docs/context-graph/architecture.md`](https://github.com/potpie-ai/potpie/blob/main/docs/context-graph/architecture.md).

## License

This project is licensed under the Apache 2.0 License. See [LICENSE](https://github.com/potpie-ai/potpie/blob/main/LICENSE).


## Community & Support

- [GitHub Issues](https://github.com/potpie-ai/potpie/issues): bugs and repository-scoped requests
- [Discord](https://discord.gg/ryk5CMD5v6): community discussion and support
- [Docs](https://docs.potpie.ai): setup, product guides, and integration details

New contributions are always welcome. Read the [Contributing Guide](https://github.com/potpie-ai/potpie/blob/main/.github/CONTRIBUTING.md)
to set up your environment, understand the workflow, and open a pull request.

<a href="https://github.com/potpie-ai/potpie/graphs/contributors">
  <img src="https://contributors-img.web.app/image?repo=potpie-ai/potpie" alt="Contributors" />
</a>

---

### Overview (`potpie/cli/README.md`)

# Potpie CLI (`potpie`)

The command-line entrypoint for the context graph. Context operations route
through the typed engine client; product operations use explicit Potpie-owned
services from one local runtime composition.

- **Entrypoint:** `potpie/cli/main.py` (registered as the `potpie`
  console script in `pyproject.toml` → `[project.scripts]`).
- **Command groups:** `potpie/cli/commands/` — one module per
  `cli-flow.md` section (`bootstrap`, `query`, `pots`/`source`, `host`,
  `daemon`, `ledger`, `graph`, `timeline`, `backend`, `skills`, `cloud`).
- **Cross-cutting contract:** `commands/_common.py` owns `--json` output, the
  exit-code map (0 ok / 1 validation / 2 unavailable / 3 degraded / 4 auth), the
  structured error shape (`code`/`message`/`detail`/`recommended_next_action`),
  origin routing (local or managed host) and active-pot resolution. An unbuilt
  capability surfaces as the structured not-implemented contract
  (`CapabilityNotImplemented`), never a traceback.

## Agent compatibility commands

The CLI exposes four compatibility commands with distinct response contracts:

- `potpie resolve` and `potpie search` return an `AgentEnvelope` with no
  server-side synthesis.
- `potpie record` returns a record receipt containing `status`, `record_id`,
  and `mutations_applied`.
- `potpie status` returns readiness information for the selected pot and scope,
  including the recommended recipe.

| Command | Use |
|------------|-----|
| `potpie resolve` | Primary bounded-context wrap for a task. |
| `potpie search` | Narrow follow-up lookup. |
| `potpie record` | Record a durable learning (decision, fix, preference, …). |
| `potpie status` | Cheap pot/scope readiness + recommended recipe. |

Integration credential status is intentionally outside this four-tool surface:
use `potpie auth status [--verify]` for local provider auth state.

## Managed hosts

The same commands run against a hosted context graph. The local daemon needs no
configuration; a managed host needs its base URL and an API key, stored in
`<home>/cli_hosts.json` (`0600`) by `potpie host set`:

```bash
printf '%s\n' "$KEY" | potpie host set https://your-host.example --token -
potpie host use managed          # make it the active host
potpie pot list --managed
potpie pot use managed:<id> --also-default-for-current-repo
potpie host use local            # back to this machine
```

- The key never travels on argv: `--token -` reads it from stdin, and a literal
  `--token <key>` is refused. `POTPIE_MANAGED_URL` / `POTPIE_MANAGED_TOKEN`
  override the stored host for one run.
- One command's host is the prefix of a qualified ref (`managed:<ref>`,
  `local:<ref>`), else the global `--host local|managed`, else the active host.
  A bare ref resolves on that host only; when it is not there, the error names
  the other host's match.
- Engine commands on a managed host go through `RemoteEngineClient`
  (`potpie.runtime.remote`); pot, source, grant, `whoami` and `status` commands
  go through the host's `PotDirectory` (`potpie.pots.directory`). `daemon`,
  `skills`, `setup`, integration `auth` and harness configuration always stay
  on this machine.
- `potpie ui --pot managed:<id>` opens the explorer served by the local daemon
  with `?host=managed`; the daemon holds the key, the browser never sees it.
- A host older than this client answers `not_implemented` (exit `2`) for what
  it lacks; everything else keeps working.
- Python callers use `potpie.runtime.connect()`: `HostConnection.engine(selector)`,
  `.pots` (synchronous, any thread) and `.extension(name, request, result_type)`;
  synchronous code runs them with `.run(awaitable)`.

The full contract is in
[`docs/context-graph/cli-flow.md`](../../docs/context-graph/cli-flow.md#hosts-commandshostpy).

## Authoritative reference

The full command catalog, flags, profiles (local vs managed), and the output
contract live in **[`docs/context-graph/cli-flow.md`](../../docs/context-graph/cli-flow.md)**.
The end-state architecture (services, ports, composition roots) is in
**[`docs/context-graph/architecture.md`](../../docs/context-graph/architecture.md)**.

Run `potpie --help` (or `python -m potpie.cli.main --help`) to list
the live commands.

## Local install (this repo)

Repo-local development installs the CLI with:

```bash
make cli-install
make cli-status
```

That path builds the graph-explorer UI, stops any old daemon, and installs the
editable package. Published-package users should use `uv tool install potpie` or
`pip install potpie` instead.

## Agent harness install

`potpie skills install [<id>] --agent claude` materializes the packaged skill
bundle into an agent harness through the explicit root skill service. The
default scope is global, so skills are installed once into the selected
harness's user-level skills directory.

The shipped templates live under `potpie/cli/templates/`, with one source for
each kind of file:

- `agent_bundle/.agents/skills/` — every skill. All harnesses install their
  skills from here, remapped to the harness's own skills directory.
- `routing/POTPIE.md` — the compact instruction block merged into a harness's
  `AGENTS.md` or `CLAUDE.md`, globally and per repository.

| Harness | Global path |
|---------|-------------|
| Cursor | `~/.cursor/skills/<skill>/SKILL.md` |
| Claude Code | `~/.claude/skills/<skill>/SKILL.md` |
| OpenCode | `~/.config/opencode/skills/<skill>/SKILL.md` |
| Codex | `$HOME/.agents/skills/<skill>/SKILL.md` |

`POTPIE_HARNESS_HOME` moves these roots away from the real home directory (the
test suite pins it); `CONTEXT_ENGINE_HOME` deliberately does not, because a
harness keeps reading its own home whatever Potpie's state directory is.

For harnesses with documented file-backed global instructions, a bundle install
or update (no skill id) also refreshes the compact Potpie managed block in
`~/.claude/CLAUDE.md` and `~/.codex/AGENTS.md`; naming one skill id installs
only that skill. Existing user-authored content is preserved; Potpie only
appends or updates the `` / `` managed
section, and `skills remove --all` takes that section back out.

Remove one global skill with `potpie skills remove <id> --agent claude`, or
delete every globally installed Potpie skill for a harness with
`potpie skills remove --all --agent claude`. Use `--scope project --path .` for
repo-local cleanup. A skill removed by id is remembered as disabled, so a later
bundle install skips it until it is installed again by id.

`potpie --json skills status --agent <harness>` lists `installed`, `missing`,
`outdated`, `drifted` (installed, but its files no longer match the bundle — a
subset of `outdated` with the same repair) and `disabled`. `--path` is resolved
against the caller's working directory and must already exist.

Use `--scope project --path .` for repo-local installs. The bundle teaches
feature / debugging / review / operations / docs / onboarding workflows over
the CLI graph surface. Agents see an advisory `skills` block in `potpie status` with missing/outdated
skills and the exact install command. Repo-local `AGENTS.md` and `CLAUDE.md`
files are merged the same way as global instruction files, so setup does not
replace existing agent instructions.

---

### Overview (`potpie/integrations/README.md`)

# potpie-integrations

External integrations for Potpie: OAuth providers, `project_sources`, Linear sync, and HTTP routers.

Layout mirrors `context-engine` (hexagonal):

- `integrations/domain/` — registry, provider definitions, shared schemas
- `integrations/application/` — services, provider bootstrap
- `integrations/adapters/outbound/` — persistence models, OAuth clients, Linear GraphQL, crypto
- `integrations/adapters/inbound/http/` — FastAPI routers mounted by the main app

The installable Python package root is `integrations` (not top-level `domain`/`application`) so it does not collide with the `context-engine` editable install.

---

### Overview (`potpie/parsing/README.md`)

# Parsing

Extract code graphs from repositories.

## Rust

```rust
use parsing_rs::{extract_graph, GraphPayload, NodePayload, RelationshipPayload};

let graph: GraphPayload = extract_graph("/path/to/repo");

// Nodes: FILE, CLASS, INTERFACE, FUNCTION
for node in graph.nodes {
    println!("{}: {} @ {}:{}", node.node_type, node.name, node.file, node.line);
}

// Relationships: CONTAINS, REFERENCES
for rel in graph.relationships {
    println!("{} → {}", rel.source_id, rel.target_id);
}
```

## Python

```python
from parsing.py_graph import create_graph
import networkx as nx

G = create_graph("/path/to/repo")  # Returns NetworkX MultiDiGraph
```

## Node Types

- `FILE` - Source file
- `CLASS` / `INTERFACE` - Type definitions
- `FUNCTION` - Methods and functions

## Relationship Types

- `CONTAINS` - FILE → CLASS/FUNCTION
- `REFERENCES` - FUNCTION → FUNCTION/CLASS (calls, usage)

## Supported Languages

Python, Rust, JavaScript, TypeScript, Go, Java, C, C++, Ruby, PHP, C#, Elixir, OCaml, Elisp, Elm, QL

## Build

```bash
cd app/src/parsing && maturin develop
```

## Note

This crate creates **graphs only**. Embeddings and search are handled by the parent `feat/colbert` module (uses Qdrant + Neo4j).

## FFF Workspace Search (Lexical)

The FFF search surface indexes a checked-out workspace folder on demand and provides in-memory path/content search.

```rust
use parsing_rs::{build_workspace_index, search_files};

let index = build_workspace_index("/path/to/workspace").expect("workspace should be indexed");
let files = index.search_files("auth", 5);

assert!(!files.is_empty());
```

```python
import parsing_rs

index = parsing_rs.build_workspace_index("/path/to/workspace")
print(index.file_count(), index.content_file_count())
print([(r.path, r.score) for r in index.search_files("auth", 5)])
```

This is lexical, in-memory search only. It intentionally does not do semantic matching.
Pre-sandbox `list_files` behavior (`potpie/parsing/src/git.rs`) is unchanged.

---

### Overview (`spec/modules/README.md`)

---
id: SPEC-MODULES-INDEX
title: Context Runtime Module Index
kind: index
revision: 1
maturity: draft
owners:
  - team:potpie
depends_on: []
change_ref: null
---

# Context Runtime Module Index

The module contracts divide root Potpie capability ownership, domain behavior,
Potpie lease and resource management, process hosting, and presentation into
separate boundaries.

## Read Order

1. [Potpie Capabilities](potpie-capabilities.md)
2. [Context Engine](context-engine.md)
3. [Potpie Resource Manager](potpie-resource-manager.md)
4. [Daemon](daemon.md)
5. [CLI](cli.md)

Read [the system contract](../system.md) first for the controller path, hosted
domain path, trust boundary, error taxonomy, and destructive-intent flow.

## Ownership Summary

| Module | Owns | Excludes |
|---|---|---|
| Potpie capabilities | Root configuration, pots, skills, setup, identity lifecycle, cross-capability composition | Context Engine domain semantics, daemon protocol, CLI presentation, generic source-layer buckets |
| Context Engine | Thin domain façade, one bound identity, focused domain modules, typed engine outcomes | Product selection, authorization policy, provisioning, daemon, presentation |
| Resource Manager | Selection, authorization, host resources, explicit dependency composition, authorized context leases | Engine-operation dispatch, generic results, service lookup, daemon transport, presentation |
| Daemon subsystem | External controller, one foreground runtime, discovery, authenticated readiness, typed handlers | Domain semantics, terminal behavior |
| CLI | User intent, controller/client selection, human and machine presentation, streams and exits | Resource composition, daemon internals, domain semantics |

## Execution Direction

```text
CLI -> typed daemon handler -> handler requests lease from Resource Manager
                            -> Resource Manager issues lease
                            -> handler invokes Context Engine
```

The Resource Manager is in the acquisition path but not the domain-operation
dispatch path. Daemon context-domain handlers depend directly on the Context
Engine façade for invocation and on the Resource Manager for lease acquisition.
Context Engine does not import or depend on any Potpie product boundary.

The Potpie capability contract makes the root ownership structure explicit
without changing this execution direction.

## Lifecycle Direction

```text
CLI -> external daemon controller -> foreground runtime
CLI -> typed daemon client -> live readiness and runtime operations
```

Controller process observation never substitutes for the authenticated
readiness handshake.

## Deferred Detail

The exact public engine API, lease representation, Resource Manager cache
policy, controller API, daemon implementation source and protocol, concurrency
matrix, destructive-intent representation, machine JSON envelope, and
compatibility sequence are recorded in
[the question log](../questions/open.md).

## Conformance

These are accepted target contracts. No module implementation or verification
claim exists until a conformance record pins an accepted contract and
implementation ref.

---

### Overview (`potpie/context-engine/src/potpie_context_engine/benchmarks/README.md`)

# Context Engine Benchmarks

A live, no-mock benchmark harness for the context engine. Every scenario:

1. Creates an **ephemeral pot** on a configured engine.
2. (If declared) loads the **canonical Acme universe seed** — services, team, ADRs, runbooks — through the same `/events/reconcile` path scenarios use.
3. Replays a sequence of **real recorded webhook payloads** (GitHub PRs, Linear issues, Slack messages, repo docs, alerts, deploys) plus any **distractor events** declared by the scenario, interleaved on the ingestion timeline.
4. Snapshots the resulting graph and asserts **ingestion / ontology quality** (right entities, right edges, no soft-downgrades, and — new — that the graph stays *clean* of distractor entities the scenario explicitly forbids).
5. Issues a `context_resolve` call and asserts **retrieval correctness** (cited the right events, used the right include keys, did not cite distractor events, and — for TIME scenarios — stayed inside the declared window).
6. Hands the response to an **Opus LLM judge** that scores synthesis quality against a per-scenario rubric, with optional per-dimension attribution for composite scenarios.
7. Drops the pot.

The full design lives in `docs/context-graph/bench-plan.md`. This file documents how to *run* the bench; the plan documents what it *measures*.

## Why no mocks

The previous benchmark mocked the reconciliation agent and used canned `MockIntelligenceProvider` responses, which is exactly what the headline 81 % score was measuring. The redesign rejects that: reconciliation is the engine's product, so the bench drives it for real.

## Use-case taxonomy (v3, 2026-05-20)

Four **knowledge dimensions** + a composite modifier:

| Code | What it tests | Default weights (ing / ret / syn) |
|---|---|---|
| `PREF`  | Project preferences — conventions, ADRs, framework choices | 20 / 40 / 40 |
| `INFRA` | Project infra / architecture — topology, owners, environments | 30 / 40 / 30 |
| `TIME`  | Timeline — recent changes, chronology, change-to-symptom links | 40 / 30 / 30 |
| `BUG`   | Bug repo — prior incidents, fixes, recurrence detection | 25 / 35 / 40 |
| `COMBO` | Composite scenarios that exercise ≥ 2 of the above | 30 / 35 / 35 |

`COMBO` scenarios declare `dimensions: [PREF, INFRA]` (etc.); the report breaks their score out per dimension.

## Layout

```
/* Detailed source-code truncated for AI context efficiency. */
```

## Multi-source ingestion

The engine ships production-grade readers for **GitHub** and **Linear**, and a minimal **Notion** reader. The four other connector kinds the bench uses — **Slack**, **Repo Docs**, **Alerting**, **Deploy** — are wired up as *passive stub connectors* (see `potpie/context-engine/adapters/outbound/connectors/_bench_stubs.py`). They emit a minimal `ReconciliationPlan` per envelope (one canonical entity, no edges) and let the reconciliation agent do the rest. The contract for swapping in a production reader later is the same `SourceConnectorPort` they implement.

## Two ways to run the engine

The bench can drive the engine two ways:

1. **HTTP (default).** Talks to a running engine over `POTPIE_BENCH_API_URL`
   — which is the app's own API port (`:8001`). Needs gunicorn on 8001 + a
   Celery worker on the `context-graph-etl` queue.
2. **In-process (`--local` / `POTPIE_BENCH_INPROCESS=1`).** Builds the engine
   container in-process and reconciles **inline** against the shared
   Postgres/Neo4j — **no HTTP server on :8001 and no Celery worker required**.
   This keeps the bench non-blocking for the rest of the app (the app keeps
   8001) and is fully self-contained. Ingestion is single-threaded and
   batched (submit-all → reconcile-once → read-back), which also sidesteps the
   threads-pool *"Event loop is closed"* reconciliation race. Implemented in
   `core/local_engine.py` (`InProcessEngineClient`).

```bash
# In-process: needs only Postgres + Neo4j up (e.g. `make infra-up`) + OPENAI_API_KEY.
python -m benchmarks run --local --tier quick --ingest-timeout 600
python -m benchmarks run --local --scenario infra_topology_basic --skip-judge
```

## Configuration

For the **HTTP** path the bench talks to a real engine. Required environment:

| Variable | Purpose |
|---|---|
| `POTPIE_BENCH_INPROCESS` | `1` to run the engine in-process (no :8001 server, no worker). Same as `run --local`. |
| `POTPIE_BENCH_API_URL` | (HTTP path) Engine base URL (e.g. `http://127.0.0.1:8001`). Falls back to `POTPIE_API_URL`. |
| `POTPIE_BENCH_API_KEY` | (HTTP path) API key for an account that can create pots. Falls back to `POTPIE_API_KEY`. |
| `OPENAI_API_KEY` | Used by the LLM judge (default model `gpt-5.4`). |
| `POTPIE_BENCH_JUDGE_MODEL` | Optional. Override the judge model. Default `gpt-5.4`. |
| `POTPIE_BENCH_REPO` | Optional. `owner/repo` to attach to each ephemeral pot. Default `acme/sandbox`. |
| `CONTEXT_ENGINE_RECONCILIATION_MODEL` | Optional. Reconciliation-agent model on the engine side. Default `openai-responses:gpt-5.4-mini`. |

## Running

```
/* Detailed source-code truncated for AI context efficiency. */
```

## Operational commands explained

- **`smoke`** — runs every scenario through the bench-side pipeline (fixture resolution, evaluator math, reporting) against synthetic snapshots. No engine required. Sub-second. The right gate on every PR — catches harness regressions before the long run.
- **`probe`** — pre-flight against the live engine. Confirms `/context/status` returns the expected connector kinds, then submits one minimal envelope per kind and waits ~15 s for it to leave `queued`. If reconciliation is dead this command finishes in seconds with a clear diagnostic instead of 11 minutes of timeouts mid-run.
- **`run --concurrency N`** — pots are isolated by construction, so scenarios run in parallel safely. See "Concurrency & progress" below for how the two engine modes parallelize differently.
- **`run-light`** — curated 5-scenario subset (one per dimension: PREF / INFRA / TIME / BUG / COMBO), 5-way parallel, **invariant** judging on. The fastest end-to-end signal we have. Use it as the smoke test before a full `run`.

## Schema-independent (invariant) grading

The default judge grades the agent's answer against a per-scenario rubric whose criteria name specific fixture ids (OPS-218, ADR-021), and the deterministic axes grade against the engine's graph shape and the include vocabulary it currently happens to expose. Both move when the ontology / read trunk changes, even when the agent's answer is just as good.

`run --invariant` (and the default `run-light`) replaces that with a single judge call that grades the answer against **only the scenario's input signal events** — no rubric, no fixture ids, no include vocab. Four 0..100 sub-scores:

| Score | Question |
|---|---|
| `faithfulness` | Every concrete claim in the answer is supported by the events. Hallucinated identifiers / people / fixes collapse this score. |
| `coverage`     | The answer surfaces the facts a careful reader of the input events would consider essential to the question. |
| `clarity`      | The answer is structured so a working engineer can understand what happened in seconds. |
| `usefulness`   | The answer concretely helps the user do whatever the question implies (debug, decide, follow a convention, plan a change). |

Aggregate: weighted mean (default 30/30/20/20 — faithfulness + coverage carry 60 %, so cosmetic gains can't paper over hallucinations or omissions). Pass score: 65.

In invariant mode the ingestion + retrieval axes still run end-to-end (the engine has to actually ingest the events and answer the query) but they don't gate pass/fail and they don't enter the aggregate. They stay in the JSON report as diagnostics so you can still see e.g. "the engine is now ignoring this include key" alongside the answer-level score.

The judge is configurable via `POTPIE_BENCH_INVARIANT_JUDGE_MODEL` (falls back to `POTPIE_BENCH_JUDGE_MODEL`, default `gpt-5.4`).

## Concurrency & progress

**Progress logging.** A run prints a start banner (mode / concurrency / tier / judge), a `[i/N]` line per scenario with phase breadcrumbs (events ingested → reconcile time → graph snapshot size → retrieval → synthesis), a live `passed k/done` tally, and an end summary (total time, per-scenario pass/fail, slowest scenarios). `benchmarks.*` logs show by default; `-v` / `--verbose` additionally surfaces the chatty engine + HTTP loggers.

**Concurrency model** (`--concurrency N`):

| Engine mode | How `--concurrency N` parallelizes | Why |
|---|---|---|
| **HTTP** (default) | N **threads** fan scenarios out; each thread submits + polls over HTTP while reconciliation runs in the engine's Celery worker. Within a scenario, seeds + distractors submit via a 6-worker pool; signals stay ordered. | HTTP calls are thread-safe; the worker does the heavy LLM work. Throughput is bounded by the worker's pool (run it `--pool=solo` to dodge the agent's threads race). |
| **In-process** (`--local`) | N **processes** (one scenario per subprocess). Within a scenario, ingestion is single-threaded (submit-all → reconcile-once → read-back). | The reconciliation agent does a per-batch `asyncio.run`; sharing a process across scenarios (threads) hits an "Event loop is closed" race. Separate processes give each scenario its own loop / agent / DB session and genuinely parallelize the LLM-bound reconciliation. |

**Cost note.** The dominant per-scenario cost is the reconciliation agent — one LLM run over the scenario's events. The 12-event Acme universe is re-reconciled per scenario (e.g. ~4–5 min for a ~19-event scenario), and `hard`/`adversarial` scenarios add heavy distractors. So: prefer `--concurrency` (≈ linear speedup, bounded by CPU/LLM rate limits), keep the judge off (`--skip-judge`) for cheap dry runs, and dial distractor `count:` down if a run is too slow. (A future `--reuse-pot` that seeds the universe once is the biggest remaining win — see bench-plan §10.)

## Authoring a new scenario

1. Pick a use case (`PREF`, `INFRA`, `TIME`, `BUG`, or `COMBO`).
2. Capture or reuse the **raw event envelopes** the scenario depends on. See `fixtures/README.md`.
3. Write a `<id>.yaml` under `use_cases/<USE_CASE>/scenarios/`. Schema (everything past `judge:` is unchanged from v1; everything above is v3):

```
/* Detailed source-code truncated for AI context efficiency. */
```

### Opt-in sub-axis gates

Coverage and precision are reported as separate numbers on every scenario, but they do not block pass/fail by default. Scenarios that *care* about a specific recall or purity floor can declare it:

```yaml
post_ingest_assertions:
  coverage_floor: 80      # ingestion axis fails if <80% of assertions match
  precision_floor: 90     # ingestion axis fails if forbidden entities slip in
  # ... structural assertions ...

retrieval_assertions:
  coverage_floor: 100     # every must_cite event must actually be cited
  precision_floor: 80     # forbidden citations cost real points
  # ... must_cite_event_id etc. ...
```

Without these fields, sub-axes are diagnostic-only and never gate.

Every scenario is graded on three axes (ingestion / retrieval / synthesis) plus two sub-axes (coverage / precision). The aggregate scenario score is the weighted mean of the primary axes; weights come from the use case unless `axis_weights:` overrides.

## Distractor generation

Hand-authoring 25:1 distractor ratios is busywork. Use the generator:

```bash
python -m benchmarks.tools.gen_distractors \
    --template fixtures/raw_events/github/pr_merged__998__inventory_unrelated.json \
    --count 25 \
    --id-prefix noise_inventory_pr \
    --out fixtures/raw_events/noise/inventory/
```

Then reference the output directory in the scenario via a glob:

```yaml
distractor_events:
  - { event: "noise/inventory/noise_inventory_pr__*.json", at: "-21d..-7d", count: 25 }
```

## Tiers

- **quick** — runs on every PR. Target 30 scenarios total. Each scenario should complete in < 90 s (excluding the LLM judge, which is opt-in via `--skip-judge` for cheap dry runs).
- **extended** — runs nightly or before a release. No hard cap; expect minutes-to-hours. Target 60 scenarios.

## Reports

Reports are JSON-first (`reports/<timestamp>.json`) with a `--format markdown` view for humans. Four aggregation panels appear in every report:

- **By use case** — count, aggregate, ingestion / retrieval / synthesis, coverage, precision.
- **By dimension** — composite scenarios decomposed across PREF / INFRA / TIME / BUG.
- **By difficulty** — easy / medium / hard / adversarial curve.
- **By source mix** — single / dual / full / adversarial curve.

A baseline diff (`--baseline reports/main.json`, planned) highlights regressions per panel cell so a small synthesis dip on `hard` doesn't get hidden by a stable easy-tier retrieval score.

---

### Index (`docs/index.md`)

---
title: Potpie
description: Turn your codebase and software development lifecycle into a living context graph for AI agents.
---

## Overview

[Potpie](https://potpie.ai) turns your codebase and software development lifecycle into a living context graph for AI agents.
It indexes code, structure, decisions, source history, team knowledge and engineering workflows, so agents can answer questions, plan changes, debug failures, and write code with project-specific context.



## Start here

- [Installation](./getting-started/installation.md)
- [First steps](./getting-started/first-steps.md)
- [Integrations and coding harnesses](./guides/integrations.md)
- [CLI reference](./reference/cli.md)
- [Context Graph](./context-graph/index.md)

## Architecture

Potpie's current architecture is CLI-first. CLI is designed to be used by both humans and agents. Read the deeper architecture notes in [Context graph architecture](./context-graph/architecture.md).

## License

This project is licensed under the Apache 2.0 License. See [LICENSE](https://github.com/potpie-ai/potpie/blob/main/LICENSE).

## Community and support

- [GitHub Issues](https://github.com/potpie-ai/potpie/issues): bugs and repository-scoped requests
- [Discord](https://discord.gg/ryk5CMD5v6): community discussion and support
- [Docs](https://docs.potpie.ai): setup, product guides, and integration details

New contributions are always welcome. Read the [Contributing Guide](https://github.com/potpie-ai/potpie/blob/main/.github/CONTRIBUTING.md)
to set up your environment, understand the workflow, and open a pull request.

---

### Context Graph Index (`docs/context-graph/index.md`)

---
title: Context Graph
description: "Durable project memory for AI agents: claims, pots, and the graph workbench."
---

## Overview

The Context Graph is Potpie's durable, shared **project memory for AI agents** — a
compact store of sourced **claims** (decisions, ownership, infra topology, prior
bugs/fixes, conventions, features) so an agent doesn't rebuild context from raw
code, PRs, tickets, and chat on every task. Humans and agents talk to the **same
`potpie` CLI**. The `potpie graph …` workbench is **shipped today**
(data-plane contract `v1.5`, ontology `2026-06-graph`) alongside the legacy
`resolve`/`search`/`record` compatibility wrappers — there is no separate "future
V2." The same Pot Management, Graph, and Skill Manager service modules run inside
either a local daemon or a managed backend API; **state stays local by default**.

```mermaid
flowchart TB
  cg_human["Humans"]
  cg_agent["Agents<br/>(Claude Code, Codex, Cursor…)"]
  cg_cli["potpie CLI<br/>(graph workbench + V1 wrappers)"]

  cg_human --> cg_cli
  cg_agent --> cg_cli

  subgraph cg_local["Local profile — shipped, default"]
    direction TB
    cg_daemon["local daemon<br/>(detached by default)"]
    cg_services["services:<br/>Pot Mgmt · Graph · Skill Manager"]
    cg_backend["GraphBackend<br/>(default falkordb_lite, embedded)"]
    cg_daemon --> cg_services --> cg_backend
  end

  subgraph cg_managed["Managed backend — roadmap"]
    direction TB
    cg_api["managed API<br/>(same service modules)"]
    cg_hosted[("hosted stores")]
    cg_api --> cg_hosted
  end

  cg_ledger["Event Ledger<br/>(source-event service: webhooks · polling · cursors)"]

  cg_cli --> cg_daemon
  cg_cli -. "managed routing<br/>(CapabilityNotImplemented)" .-> cg_api
  cg_services -. "pull events (external clients are stubs)" .-> cg_ledger
```

## Target OSS default

**Published package** (PyPI):

```bash
uv tool install potpie   # recommended
# or: pip install potpie
potpie setup        # provisions config, local stores, the default pot, the daemon, and skills
potpie status
```

The base package is the complete local product (CLI, daemon, local graph
backend). Semantic search with sentence-transformers is the optional
`embeddings` extra (`uv tool install 'potpie[embeddings]'`); without it, setup
falls back to the bundled hashing embedder.

**Repo-local development** (this checkout): use `make cli-install` — it builds the
graph-explorer UI, stops any old daemon, and installs the editable CLI. Do not use
raw `uv tool install --editable ./potpie/context-engine` for day-to-day reinstalls.

```bash
make cli-install
make cli-status     # confirm PATH / uv-tool install
potpie setup
potpie status
```

`setup` also registers your repo as a source. It does not scan the working tree
— harness-led ingestion writes the graph. The OSS/CLI default backend is **`falkordb_lite`** —
an embedded FalkorDB over a local file, with **no Docker, server, Neo4j, or cloud
key required**; override it with `--backend` or `CONTEXT_ENGINE_BACKEND`
(precedence: `CONTEXT_ENGINE_BACKEND` > legacy `GRAPH_DB_BACKEND` >
`falkordb_lite`). Full flags live in [`cli-flow.md`](./cli-flow.md).

> **Roadmap (not yet wired):** Managed-backend routing is designed but not
> functional — `pot use --managed`, `pot list --managed`, and the whole `cloud`
> group raise `CapabilityNotImplemented`. The **external** Event Ledger clients
> (`ledger pull/query` against real providers) are TODO stubs. The live "ledger"
> today is the internal Postgres event store described in
> [`ingestion-nudge.md`](./ingestion-nudge.md).

## Start here

| Doc | What it answers |
|---|---|
| [`vision.md`](./vision.md) | What the Context Graph is and why; claims-not-payloads; harness-owned intelligence; the three product boundaries (local OSS / managed [roadmap] / Event Ledger [roadmap]); pots-as-tenancy; anti-goals. |
| [`architecture.md`](./architecture.md) | Hexagonal layers; the two composition roots (local agent spine vs ingestion server); the daemon model; the `GraphBackend` port + 6 capabilities + the backend coverage table; per-pot scoping, the resource store for document payloads, and backend selection. |
| [`ontology.md`](./ontology.md) | The three declarative catalogs (25 entity types / 27 predicates + `RELATED_TO` / record types); contract constants (versions, 7 truth classes, 10 mutation ops, 6 source authorities); 8 subgraphs / 10 views; identity keys and the environment qualifier. |
| [`querying.md`](./querying.md) | Reading: the CLI Graph Surface Lite and compatibility commands; the single read trunk and 10 readers; the `AgentEnvelope` (ranked evidence, no server-side answers); ranking; the 3-axis model (Retrieve / Filter / Traverse — all shipped). |
| [`writing.md`](./writing.md) | Writing: the flat semantic-mutation DSL (10 ops); validation + runtime risk; the canonical write door `graph propose` → `graph commit --verify` (with `graph mutate` and `record` as the legacy wrappers); revision-based concurrency and receipts; inbox; quality. |
| [`ingestion-nudge.md`](./ingestion-nudge.md) | How raw episodes/events enter; the internal Postgres event store vs the external Event Ledger seam; connectors (github/notion only); windowed reconciliation (off by default); the zero-token nudge trigger model. |
| [`skills.md`](./skills.md) | Harness-owned intelligence; the bundled CLI skills (potpie-graph teaches propose/commit); how an agent acts on nudges; the separate server-side reconciliation skill surface (not the same thing). |
| [`cli-flow.md`](./cli-flow.md) | The full `potpie` command reference, grouped, with flags, exit-code contract, snapshots, and the canonical journey. |
| [`observability.md`](./observability.md) | What logs, traces, metrics, and readiness report; span names. |
| [`bench-plan.md`](./bench-plan.md) | How graph quality is validated across backends (invariant judge, `run-light`). |

## Vocabulary

| Term | Meaning |
|---|---|
| **Pot** | Unit of isolation/tenancy. Every query, source, inbox item, claim, semantic mutation, and graph operation is scoped to one pot; the pot id **is** the storage `group_id`. A pot is local or managed; the active pot determines routing. Cross-pot federation is an anti-goal. |
| **Daemon** | Local background process (`potpie/daemon/lifecycle.py`) for lifecycle, IPC, health, and logs — **not** the business layer. Default host mode is detached (`daemon`); it also serves the read-only `potpie ui` explorer. |
| **Services** | Root capability services own pots, sources, setup, skills, and local identity; Graph Service owns graph reads, semantic mutations, and the workbench. Runtime composition assembles them for the local daemon. |
| **GraphBackend** | Swappable capability bundle of 6 ports — canonical `mutation` + `claim_query`, plus rebuildable projections `semantic`, `inspection`, `analytics`, `snapshot` — with `capabilities()`/`provision()`. Default profile `falkordb_lite`. |
| **Skill Manager** | CLI-managed skill catalog/installer for agent harnesses. Skills teach agents how to use the CLI; they are not graph facts or new tools. |
| **Event Ledger** | Separate managed-or-self-hostable source-event service (webhooks, polling, replay cursors). Graph consumers *pull* and track their own cursor/apply state; the ledger is **not** the graph source of truth. (External clients are stubs — roadmap.) |
| **Claim** | A compact, sourced fact — the graph stores claims + source refs, **never** full payloads (diffs, doc bodies, transcripts). Each claim carries a truth class that feeds the ranker. |
| **Retrieval card** | The single text a claim is embedded and searched as (agent-authored `description` leads). One builder is shared by the embed-on-write and read paths, so retrieval quality tracks the description the agent writes. |

The active package lives under
[`potpie/context-engine/`](https://github.com/potpie-ai/potpie/tree/main/potpie/context-engine). Where any older
note disagrees with these docs, these docs (code-verified on `main`) win.

---

### Async Migration Plan (`docs/async-migration-plan.md`)

---
title: Async migration plan
description: Step-by-step plan to replace blocking libraries with native async equivalents.
---

## Context

The previous PRs used `asyncio.to_thread` to wrap blocking calls. The code review flagged:
- Thread-safety bugs (DB sessions passed to worker threads)
- Unbounded queue in custom stream generator
- Daemon thread-per-stream with no cap
- `StreamingResponse` with sync generator already runs in Starlette's thread pool (~40 tokens) — the custom thread+queue was unnecessary overhead
- Thread pool saturation risk under load

**New direction:** Replace blocking libraries with native async equivalents. Use `to_thread` only as a temporary bridge for code that cannot be converted yet. Measure every step.

---

## Step 0: Revert thread-based changes

**Branch:** `fix/wait-for-task-start-thread` (5 commits)

Revert these changes that introduced `asyncio.to_thread` wrappers and the custom stream generator:

| File | What to revert |
|------|----------------|
| `conversation_routing.py` | Remove `redis_stream_generator_async`, revert `start_celery_task_and_stream` to sync, remove `asyncio.to_thread` around `ensure_unique_run_id` and `wait_for_task_start` |
| `conversations_router.py` | Remove `asyncio.to_thread` wrappers around `session_service`, `redis_manager`, `ensure_unique_run_id`; restore `local_mode` / request / UA detection in `create_conversation` |
| `conversation_service.py` | Remove `asyncio.to_thread` around `history_manager.*`, `session_service.*`, `redis_manager.*`, `celery_app.control.revoke` |
| `redis_streaming.py` | Revert `stop_event` parameter, revert xread block from 1000ms back to 5000ms. Keep `require_running` in `wait_for_task_start` (that's a correctness fix, not a threading change) |
| `api/router.py` | Remove `asyncio.to_thread` around `ensure_unique_run_id` |
| `tunnel_router.py` | Remove `asyncio.to_thread` wrappers |
| `tunnel_service.py` | Revert scan_iter and TTL changes only if they were part of the thread PR. Keep if they were independent fixes |
| `socket_service.py` | Remove `close()` method if it was added as part of the thread PR |
| `main.py` | Remove shutdown handler if it was part of the thread PR |
| `github_service.py` | Remove `asyncio.to_thread` around `redis.get`/`redis.setex` in `get_project_structure_async` |

**Keep from the thread PR:**
- `wait_for_task_start(require_running=True)` — correctness fix, not a threading change
- Timeout additions (timeout=30 on requests calls) — safety fix

**Keep from `fix/sync-timeouts-only`:**
- All timeout additions (auth_service, github_service, parse_webhook_helper)
- `parsing_controller._query_parsing_status_sync` with `asyncio.to_thread` — acceptable bridge until async DB migration


---

## Step 1: AsyncRedisStreamManager for FastAPI

**What:** Create `redis.asyncio` client alongside the existing sync client. Add async methods for all operations called from FastAPI.

**Files to change:**
- `redis_streaming.py` — add `AsyncRedisStreamManager` class using `redis.asyncio`
  - `async def get_task_status()`
  - `async def set_task_status()`
  - `async def set_task_id()`
  - `async def get_task_id()`
  - `async def publish_event()`
  - `async def set_cancellation()`
  - `async def get_stream_snapshot()`
  - `async def clear_session()`
  - `async def wait_for_task_start()` — use `asyncio.sleep` instead of `time.sleep`
- `conversation_routing.py` — use `AsyncRedisStreamManager` in `start_celery_task_and_stream` (make it `async def`), `start_celery_task_and_wait`
  - `ensure_unique_run_id` → `async_ensure_unique_run_id` using async Redis
  - Leave sync `redis_stream_generator` as-is (Starlette thread pool handles it)
- `session_service.py` — add `AsyncSessionService` using `AsyncRedisStreamManager`. Use `await async_redis.scan(...)` instead of `keys()`
- `conversations_router.py` — switch to async versions
- `api/router.py` — switch to async versions
- `conversation_service.py` — `stop_generation` uses async Redis methods

**Keep unchanged:**
- Sync `RedisStreamManager` — still used by Celery tasks (`agent_tasks.py`, `parsing_tasks.py`)
- Sync `redis_stream_generator` — Starlette runs it in thread pool

**Test script: `scripts/benchmark_redis_async.py`**
```
Measures:
1. Time to complete N concurrent POST /message requests (streaming)
2. Time to complete N concurrent GET /active-session requests
3. Time to complete N concurrent GET /task-status requests
4. Compare: before (sync Redis on event loop) vs after (native async Redis)
5. Report: p50, p95, p99 latency; requests/sec; event loop blocked time (via middleware)
```

**PR:** `feat/async-redis-stream-manager`

---

## Step 2: AsyncSession for hot-path DB services

**What:** Migrate services called from FastAPI to use `AsyncSession`.

### 2a: UsageService

**Why first:** Called on every `create_conversation` and `post_message` (check_usage_limit). Highest frequency.

- `usage_service.py` — replace `SessionLocal()` + `session.query()` with `AsyncSessionLocal` + `await async_session.execute(select(...))`
- Inject `AsyncSession` from FastAPI dependency

**Test script: `scripts/benchmark_usage_check.py`**
```
Measures:
1. N concurrent POST /conversations (each triggers check_usage_limit)
2. Latency distribution before/after
```

**PR:** `feat/async-usage-service`

### 2b: ChatHistoryService (dual-path) ✅

**Why:** Used from both FastAPI (store_message, stop_generation) and Celery (agent_tasks). Cannot just swap to async.

- Create `AsyncChatHistoryService` with `AsyncSession`:
  - `async def get_session_history()`
  - `add_message_chunk()` (in-memory buffer only)
  - `async def flush_message_buffer()`
  - `async def save_partial_ai_message()`
- `ConversationService.create()` — accepts optional `async_db`; when provided, builds `AsyncChatHistoryService` and uses it on FastAPI path
- FastAPI path uses async variant; Celery path uses existing sync `ChatHistoryService`

**Test script: `scripts/benchmark_chat_flow.py`**
```
Measures:
1. N concurrent POST /message (streaming) — end-to-end including DB writes
2. N concurrent POST /stop — stop_generation with DB save
3. Latency distribution
```

**PR:** `feat/async-chat-history-service`

### 2c: ShareChatService, AccessService, UserService ✅

- **AsyncShareChatService**: New class in `access_service.py` using `AsyncSession`; `share_chat`, `get_shared_emails`, `remove_access` (select/update/commit).
- **conversations_router**: Share/access endpoints use `get_async_db` and `AsyncShareChatService(async_db)`.
- **AsyncUserService**: New class in `user_service.py`; async `get_user_by_uid`, `get_user_id_by_email`, `get_user_by_email`, `get_user_ids_by_emails`, `create_user`, `update_last_login`. Sync `UserService` retained for Celery/main.
- **api/router**: `get_api_key_user` uses `AsyncUserService(async_db).get_user_by_uid`. Auth routes (2d) remain on sync UserService for now.

**Test script: `scripts/benchmark_share_access.py`**
```
Measures:
1. N concurrent POST /share
2. N concurrent GET /shared-emails
3. N concurrent DELETE /access
```

**PR:** `feat/async-share-access-user-services`

### 2d: Auth routes (signup, sso_login, provider endpoints) ✅

- auth_router.py: inject `get_async_db`, use `AsyncUserService(async_db)` for user lookups (get_user_by_uid, get_user_by_email, update_last_login) in signup, sso_login, get_my_account
- `UnifiedAuthService` remains sync for this PR; full async migration can follow

**Test script: `scripts/benchmark_auth.py`**
```
Measures:
1. N concurrent POST /signup
2. N concurrent POST /sso/login
3. N concurrent GET /providers/me
4. Latency distribution
```

**PR:** `feat/async-auth-db`

### 2e: GithubService DB queries ✅

- `get_combined_user_repos(user_id, async_session=...)` and `get_repos_for_user(user_id, async_session=...)` accept optional `AsyncSession`; when provided use `select()` + `await session.execute()`
- `code_provider_controller.get_user_repos` and github_router pass `async_db` into `get_combined_user_repos`

**PR:** `feat/async-github-db` (included in feat/async-share-access-user-services)

---

## Step 3: Async Redis for non-streaming services ✅

**What:** Add `redis.asyncio` to services that do simple Redis get/set from FastAPI.

| Service | Change |
|---------|--------|
| `tunnel_service.py` | ✅ Async client + `get_workspace_tunnel_record_async`, `set_workspace_tunnel_record_async`, `list_user_tunnels_async`. Sync `list_user_tunnels` uses `scan_iter` not `keys`. Workspace record `set` uses TTL (`WORKSPACE_TUNNEL_RECORD_TTL`). |
| `tunnel_router.py` | ✅ Both routes call `await tunnel_service.get_workspace_tunnel_record_async(workspace_id)`. |
| `github_service.py` | ✅ Lazy shared `_get_async_redis_cache()`; `get_project_structure_async` uses async Redis for cache get/setex when available. |
| `branch_cache.py` | ✅ Optional `_async_redis_client`; `get_branches_async` for FastAPI path. Controller `get_branch_list` uses `await branch_cache.get_branches_async`. |

**Test script: `scripts/benchmark_tunnel_github.py`**
```
Measures:
1. N concurrent GET /tunnel/workspace/{id}
2. N concurrent GET /tunnel/workspace/{id}/socket-status
3. N concurrent GET /github/user-repos (triggers branch cache / repo listing)
4. Latency distribution (p50, p95, p99); req/s
Usage: WORKSPACE_ID=<16-hex> AUTH_HEADER="Bearer <token>" CONCURRENT=5 ROUNDS=2 uv run python scripts/benchmark_tunnel_github.py
```

**PR:** `feat/async-redis-tunnel-github`

---

## Step 4: Async HTTP clients ✅

**What:** Replace `requests` with `httpx` where called from FastAPI; offload sync-only SDKs to threads.

| Location | Change |
|----------|--------|
| `auth_service.py` | Sync login uses `httpx.Client`; added `login_async` with `httpx.AsyncClient` (timeout 10s/30s). Route calls `await auth_handler.login_async`. |
| `auth_router.py` | `send_slack_message` uses `httpx.AsyncClient().post` with timeout. |
| `parse_webhook_helper.py` | `send_slack_notification` uses `httpx.AsyncClient().post` with timeout. |
| `linear_client.py` | Sync `execute_query` uses `httpx.Client` (timeout 10s/30s); added `execute_query_async`, `get_issue_async`, `update_issue_async`, `comment_create_async`. Linear tools use async methods. |
| `email_helper.py` | `resend.Emails.send` wrapped in `await asyncio.to_thread(resend.Emails.send, params)` in both `send_email` and `send_parsing_failure_alert`. |
| `posthog_helper.py` | `send_event`: when event loop is running, uses `loop.run_in_executor(None, _capture_sync)` (fire-and-forget); otherwise runs `_capture_sync` inline. |

**Test script: `scripts/benchmark_login.py`** (optional; not added in this PR)

**PR:** Combined with Step 3 in `feat/async-redis-tunnel-github`

---

## Step 5: SearchService async DB

- `search_service.py` — migrate `search_codebase` to `AsyncSession`
- `knowledge_graph_router.py` — use async session for project verification query

**PR:** `feat/async-search-service`

---

## Test infrastructure

### Shared benchmark harness: `scripts/benchmark_harness.py`

```python
"""
Usage: python scripts/benchmark_harness.py --target <script> --concurrency 10,20,50 --rounds 3

For each concurrency level:
1. Warm up (5 requests)
2. Run N concurrent requests × rounds
3. Report: min, p50, p95, p99, max latency; total wall time; requests/sec
4. Compare with baseline file if provided (--baseline results.json)
"""
```

### Event loop monitor middleware: `scripts/eventloop_monitor.py`

```python
"""
FastAPI middleware that measures event loop blocking.
Logs a warning when any request handler blocks the event loop for > 100ms.
Reports: blocked_time_ms, endpoint, method.
Run with: add middleware to app in development/staging.
"""
```

### Per-step test scripts

Each step has a dedicated script (listed above) that:
1. Hits the actual API endpoints of a running local server
2. Uses `httpx.AsyncClient` with `asyncio.gather` for concurrency
3. Requires `CONVERSATION_ID`, `AUTH_TOKEN` env vars (real auth against local)
4. Outputs JSON results for before/after comparison
5. Validates: latency improved, no errors, functional correctness (response shape)

---

## Execution order and PR strategy

| Order | PR | Risk | Impact |
|-------|-----|------|--------|
| 0 | Revert thread wrappers | Low (restores known-good state) | Removes thread-safety bugs |
| 1 | AsyncRedisStreamManager | Medium (new component) | Unblocks streaming hot path |
| 2a | Async UsageService | Low (isolated) | Unblocks every message send |
| 2b | Async ChatHistoryService | Medium (dual-path) | Unblocks message DB writes |
| 2c | Async Share/Access/User | Low (simple CRUD) | Unblocks sharing endpoints |
| 2d | Async Auth DB | High (large surface) | Unblocks login/signup |
| 2e | Async GitHub DB | Low (small) | Unblocks repo listing |
| 3 | Async Redis (tunnel/github cache) | Low | Unblocks tunnel + cache |
| 4 | Async HTTP clients | Low | Unblocks login, notifications |
| 5 | Async SearchService | Low | Unblocks search |

Each PR:
1. Run benchmark **before** (on main or previous step)
2. Merge the PR
3. Run benchmark **after**
4. Record results in `docs/async-migration-results.md`
5. If regression detected, revert before proceeding

---

## What we do NOT change

- **Celery tasks** — sync by design. No changes.
- **Neo4j** — all usage is in Celery agent tasks via `to_thread(self.run)`. Fine as-is.
- **File I/O / subprocess** — all in Celery parsing tasks. Fine.
- **Sync `redis_stream_generator`** — Starlette's thread pool handles sync iterators in `StreamingResponse`. No custom thread/queue.
- **`to_thread` inside Celery's event loop** (tool `arun` methods) — Celery creates its own loop per task; `to_thread` there is fine.

---

## Dependencies to add

```
# Already in the project:
redis           # has redis.asyncio built-in
sqlalchemy      # has AsyncSession built-in
httpx           # already used in some places

# No new packages needed — all async support is in existing deps.
```

---

## Success criteria

Per step:
- p95 latency for affected endpoints improves or stays flat
- No new errors in logs
- Event loop blocked time (via middleware) decreases
- Thread count (via `/proc` or metrics) decreases or stays flat

Overall:
- All FastAPI async routes have zero sync I/O on the event loop
- Thread pool usage is only from Starlette's managed pool for `StreamingResponse` sync generators
- Measured ~30-40% more requests/instance capacity (Duolingo benchmark)

---

### Config.Json (`docs/config.json`)

{
  "spokeId": "potpie",
  "docsPath": "docs",
  "documentationContractVersion": 1
}

---

### Releasing (`docs/releasing.md`)

---
title: Releasing Potpie
description: How a Potpie release updates documentation.
---

## Documentation release checklist

1. Update `docs/` in the same pull request as any user-facing behavior change.
2. Bump the root `[project].version` in `pyproject.toml` to a stable `MAJOR.MINOR.PATCH` version.
3. Publish a GitHub Release whose tag is exactly `vMAJOR.MINOR.PATCH`.

Do not edit `docs/config.json` during a normal release. It is one-time Hub integration metadata. The `documentationContractVersion` field opts the repository into the supported documentation contract; it is not the Potpie release version and is not bumped for each release.

Each eligible stable GitHub Release publishes its own exact documentation version. For example, `v2.0.1` publishes `/products/potpie/2.0.1/`, and `v2.0.2` publishes `/products/potpie/2.0.2/`. The product root and `latest` redirect to the highest numeric version; legacy `/2.0/` links redirect to the highest retained `2.0.x` release.

Draft and prerelease GitHub Releases do not publish stable documentation. The release workflow validates the matching tag and reports the exact documentation route before notifying the Docs Hub.

Every successful release rebuild also refreshes `/products/potpie/next/` from current `main`.

---

### Sandbox Core Setup (`docs/sandbox-core-setup.md`)

---
title: Sandbox core setup
description: "Repo cache, workspace, and runtime model for isolated agent work."
---

## Context

Potpie agents need an isolated place to inspect and change user repositories:
clone once, reuse the repo later, create worktrees for new work, run commands,
edit files, run tests, and resume the same state in a later agent session.

The important distinction is that "sandbox" is not one thing:

- A **repo cache** is the durable git mirror. It should avoid recloning.
- A **workspace** is the durable working tree or worktree where the agent sees
  files and can make changes.
- A **runtime** is the compute environment that runs commands against a
  workspace. It may be stopped, recreated, or moved without deleting the
  workspace.

The hexagonal sandbox module now lives at `app/src/sandbox/sandbox/` (domain
ports, application service, API client, outbound adapters). Local mode wires
`LocalGitWorkspaceProvider` + `LocalRepoCacheProvider` directly; parsing keeps
using `app/modules/repo_manager/repo_manager.py` for its own clone/eviction
needs, but sandbox traffic no longer routes through it.

This design keeps the repo/worktree lifecycle separate from the execution
backend. The application layer asks for a workspace, then attaches a runtime to
it.

---

## Goals

- Clone a user repo once and reuse it across agent sessions.
- Keep durable agent work in a workspace/worktree, especially for feature work.
- Let multiple execution backends run against the same workspace abstraction:
  local container, Docker, Daytona, E2B, or future providers.
- Allow the compute runtime to be hibernated or destroyed without losing the
  repo cache or workspace state.
- Support explicit eviction policies for runtimes, workspaces, and repo caches.
- Keep application code backend-agnostic without hiding important persistence
  semantics.

## Non-goals

- Building a human developer environment like VS Code Remote, Coder, or devpod.
- Making every backend expose identical networking, region, quota, snapshot, or
  preview behavior.
- Replacing the existing read-only `gvisor_runner.py` path immediately.
- Treating snapshots as the only persistence mechanism. Git worktrees and
  volumes are first-class persistence.

---

## Core Model

### RepoCache

A `RepoCache` is a durable local or remote git mirror for a repository.

In local mode this maps naturally to the existing bare repo:

```text
.repos/<owner>/<repo>/.bare/
```

Responsibilities:

- Normalize repo identity.
- Clone/fetch using the existing auth chain.
- Store metadata needed for eviction and debugging.
- Never hold uncommitted agent edits.

Key rule: repo cache is shared infrastructure. It is not the agent's mutable
working area.

### Workspace

A `Workspace` is a durable checkout/worktree created from a repo cache.

Typical examples:

- Read-only analysis workspace for `project_id + base_ref`.
- Edit workspace for `project_id + conversation_id`.
- Task workspace for `project_id + task_id`.

In local mode this maps to a git worktree:

```text
.repos/<owner>/<repo>/worktrees/<user>_<conversation>_<branch>/
```

Responsibilities:

- Provide a stable filesystem root for an agent session.
- Preserve uncommitted edits, generated files, dependency installs, test
  artifacts, and branch state until explicitly cleaned or evicted.
- Track metadata: owner user, project, repo, base ref, branch, dirty state,
  last used time, pinned status, and runtime attachment.

Key rule: persistent agent work belongs to a workspace, not to the runtime.

### SandboxRuntime

A `SandboxRuntime` is the compute environment that executes commands against a
workspace.

Examples:

- Local Docker container with workspace mounted.
- Daytona workspace runtime.
- E2B microVM.
- Local read-only gVisor subprocess path for analysis only.

Responsibilities:

- Start, stop, destroy, and report state.
- Execute commands.
- Stream long-running command output.
- Expose optional backend capabilities such as preview URLs or snapshots.

Key rule: destroying a runtime must not delete a workspace unless the caller
explicitly asks for workspace cleanup.

---

## Current State (Implemented)

The hexagonal layout described above is in place. References point at code
that is shipped today.

**Domain** (`app/src/sandbox/sandbox/domain/`)
- `models.py` — `Workspace`, `WorkspaceRequest`, `RepoIdentity`, `RepoCache`
  (declared but not yet wired through), `Runtime`, `RuntimeSpec`,
  `ExecRequest`, `ExecResult`, `Mount`, `WorkspaceMode` (`ANALYSIS`/`EDIT`/
  `TASK`).
- `errors.py` — `WorkspaceError` family (`WorkspaceNotFound`,
  `RepoCacheUnavailable`, `RepoAuthFailed`, `InvalidWorkspacePath`, …) and
  `RuntimeErrorBase` family (`RuntimeUnavailable`, `RuntimeTimeout`, …).
- `ports/workspaces.py`, `ports/runtimes.py`, `ports/stores.py`,
  `ports/locks.py` — the four ports.

**Application** (`app/src/sandbox/sandbox/application/services/sandbox_service.py`)
- `SandboxService` owns `get_or_create_workspace`,
  `get_or_create_runtime`, `exec`, `hibernate_runtime`,
  `destroy_runtime`, `destroy_workspace`. Persists workspaces and runtimes
  via the store, takes per-workspace locks for mutating commands.

**API** (`app/src/sandbox/sandbox/api/client.py`)
- `SandboxClient` is the public façade. Helpers: `read_file`,
  `write_file`, `list_dir`, `search`, `status`, `diff`, `commit`, `push`.
  Local-fs fast paths when `handle.local_path is not None`; exec-based
  fallbacks otherwise.

**Bootstrap** (`app/src/sandbox/sandbox/bootstrap/`)
- `settings.py` reads `SANDBOX_WORKSPACE_PROVIDER` /
  `SANDBOX_RUNTIME_PROVIDER` / `SANDBOX_REPOS_BASE_PATH` /
  `SANDBOX_METADATA_PATH`.
- `container.py` — `build_sandbox_container` selects providers and store.

**Adapters**
- `adapters/outbound/local/git_workspace.py` — `LocalGitWorkspaceProvider`.
  Bare-repo + worktree creation under `.repos/`, with
  `<user>_<scope>_<branch>` worktree paths to isolate per-conversation
  edits. Currently **not used in production** — the bridge below overrides
  the wiring.
- `adapters/outbound/local/subprocess_runtime.py` —
  `LocalSubprocessRuntimeProvider` for local exec.
- `adapters/outbound/daytona/provider.py` — `DaytonaWorkspaceProvider` and
  `DaytonaRuntimeProvider`. One Daytona sandbox per `(user, project)`,
  branch-named worktrees inside it.
- `adapters/outbound/docker/runtime.py` — `DockerRuntimeProvider`.
- `adapters/outbound/file/json_store.py` — `JsonSandboxStore` (durable
  metadata; default for local mode).
- `adapters/outbound/memory/store.py`, `memory/locks.py` — in-memory
  fallbacks.

**Agent tool surface** (`app/modules/intelligence/tools/sandbox/`)
- `client.py` — process-wide `SandboxClient` accessor (`get_sandbox_client`)
  and `resolve_workspace(...)` helper.
- `context.py` — contextvars (`user_id`, `conversation_id`, `branch`,
  `auth_token`) the agent harness sets at run start.
- `tools.py` exports `create_sandbox_tools()` returning four tools:
  `sandbox_text_editor`, `sandbox_shell`, `sandbox_search`, `sandbox_git`.
- `tool_functions.py` — each tool calls `_resolve(project_id, mode=...)`
  to get a `WorkspaceHandle` per call.

---

## Known Gaps (post-revamp)

Findings from the architectural audit; tracked by the **Implementation
Roadmap** below.

1. **Two parallel local providers, one is dead code.** *(resolved)*
   `LocalGitWorkspaceProvider` is the only local sandbox provider; the
   `RepoManagerWorkspaceProvider` bridge has been removed. Parsing still
   uses `RepoManager` for its own clone/eviction; sandbox traffic does not.

2. **Dual unsynchronized persistence.** *(resolved for sandbox traffic)*
   With the bridge gone, sandbox state lives only in `JsonSandboxStore`.
   Parsing's `RepoManager` metadata at
   `.repos/.meta/<owner>/<repo>/branch__commit.json` is independent and
   no longer needs to reconcile.

3. **Eviction lives outside the sandbox application layer.**
   Volume tracking and tiered eviction (worktrees first at 80%, full
   repos at 90%) are inside `RepoManager`. The sandbox module has no
   `EvictionPolicy` port and no eviction logic of its own.

4. **Parsing bypasses the sandbox abstraction.**
   `parsing_service.py:298` calls `parse_helper.clone_or_copy_repository`
   which goes directly to `RepoManager`. There is a feature-flagged
   `SandboxClient` path (`SANDBOX_PARSING_ENABLED`), but it is not the
   default and does not persist a `Workspace` past the call. After
   `update_project_status(..., READY)` at `parsing_service.py:575` no
   workspace record exists in the sandbox store.

5. **No first-class `RepoCache` entity.** `RepoCache` is declared in
   `models.py:115` but `Workspace.repo_cache_id` is always `None`. There
   is no `RepoCacheProvider` port; the bare-repo concept is implicit
   inside each adapter.

6. **`WorkspaceMode` overloads four concerns:** read-only vs writable,
   branch creation, sharing, and keying. Branch-creation logic is
   duplicated across `LocalGitWorkspaceProvider._create_workspace_sync`
   and `daytona/provider.py:_ensure_worktree`. Capabilities should be
   explicit on the workspace, derived from mode at construction.

7. **Daytona adapter leaks SDK types.** `Any`-typed sandbox handles
   (`_client_factory`, `_sandboxes`) flow through the application layer.
   `DaytonaRuntimeProvider.exec` reaches into `sandbox.process.exec`
   directly. Worktree paths use only `branch_name` (vs. local's
   `<user>_<scope>_<branch>`), which weakens isolation guarantees.

8. **No git-platform port.** PR creation is not represented in the
   sandbox layer. `commit` and `push` exist on `SandboxClient`, but
   creating a PR has to bypass the sandbox abstraction.

9. **Tool surface uses contextvars instead of explicit dependencies.**
   `tool_functions._resolve` reads `user_id` from a contextvar and looks
   up `repo_name` via DB. The agent harness sets the contextvar at
   `pydantic_agent.py:613-621`. A toolset factory should take
   `(client, run_context)` explicitly.

10. **No eviction port / no per-conversation cleanup hook.**
    `SandboxStore` lacks `list_workspaces_by_repo`,
    `find_workspaces_for_eviction`, etc. EDIT-mode worktrees are only
    reaped by `RepoManager`'s age-based eviction; no
    `release_session(conversation_id)` exists.

---

## Layered Architecture

```text
Agent / Tool layer
    |
    v
SandboxService
    - resolve repo identity
    - get/create repo cache
    - get/create workspace
    - attach/resume runtime
    - enforce locks, TTL, eviction
    |
    +--> RepoCacheStore / WorkspaceStore / RuntimeStore
    |
    +--> RepoCacheManager
    |       local implementation wraps existing RepoManager
    |
    +--> WorkspaceManager
    |       local implementation creates git worktrees
    |
    +--> RuntimeProvider port
            DockerProvider / DaytonaProvider / E2BProvider / LocalReadOnlyProvider
```

The repo cache and workspace layers own persistence. The runtime provider owns
execution.

---

## Public Service API

Tools and agents should call `SandboxService`, not backend adapters directly.

```python
# app/modules/sandbox/service.py
from dataclasses import dataclass
from typing import Literal, Optional


WorkspaceMode = Literal["analysis", "edit", "task"]


@dataclass(frozen=True)
class WorkspaceRequest:
    user_id: str
    project_id: str
    repo_name: str                 # "owner/repo"
    repo_url: Optional[str]
    base_ref: str                  # branch or commit
    mode: WorkspaceMode
    conversation_id: Optional[str] = None
    task_id: Optional[str] = None
    branch_name: Optional[str] = None
    create_branch: bool = False


@dataclass(frozen=True)
class RuntimeRequest:
    workspace_id: str
    image: str
    env: dict[str, str]
    writable: bool = True
    network: Literal["none", "limited", "default"] = "limited"
    timeout_s: Optional[int] = None


class SandboxService:
    async def get_or_create_workspace(
        self, request: WorkspaceRequest
    ) -> "Workspace":
        ...

    async def get_or_create_runtime(
        self, request: RuntimeRequest
    ) -> "SandboxRuntime":
        ...

    async def exec(
        self,
        workspace_id: str,
        request: "ExecRequest",
    ) -> "ExecResult":
        ...

    async def hibernate_runtime(self, runtime_id: str) -> None:
        ...

    async def destroy_runtime(self, runtime_id: str) -> None:
        ...

    async def destroy_workspace(self, workspace_id: str) -> None:
        ...
```

Convenience methods can exist for common tool flows:

```python
async def get_or_create_analysis_workspace(user_id, project_id) -> Workspace: ...
async def get_or_create_edit_workspace(user_id, project_id, conversation_id) -> Workspace: ...
async def run_command(workspace_id, command) -> ExecResult: ...
```

---

## Runtime Port

The provider port should be about compute, not repo cloning policy.

```
/* Detailed source-code truncated for AI context efficiency. */
```

### Optional Capabilities

Do not rely on `isinstance(provider, SomeProtocol)` unless the protocol is
runtime-checkable. Prefer explicit optional capability accessors.

```python
@dataclass(frozen=True)
class RuntimeCapabilities:
    snapshot: bool = False
    preview_url: bool = False
    interactive_session: bool = False


class SnapshotCapability(Protocol):
    async def snapshot_runtime(self, runtime_id: RuntimeId, label: str | None = None) -> str: ...
    async def restore_runtime(self, snapshot_id: str) -> SandboxRuntime: ...


class PreviewURLCapability(Protocol):
    async def expose_port(self, runtime_id: RuntimeId, port: int) -> str: ...


class SessionCapability(Protocol):
    async def open_session(self, runtime_id: RuntimeId) -> "Session": ...
```

Provider construction can expose these as attributes:

```python
provider.snapshotter: SnapshotCapability | None
provider.preview_urls: PreviewURLCapability | None
provider.sessions: SessionCapability | None
```

---

## Storage Model

Persist this in Postgres, even if the first implementation only supports local
filesystem paths.

### `sandbox_repo_caches`

One row per durable repo mirror.

Recommended columns:

- `id`
- `repo_name`
- `repo_url`
- `provider`
- `provider_host`
- `local_path`
- `auth_scope_hash`
- `last_fetched_at`
- `last_used_at`
- `size_bytes`
- `state`
- `created_at`
- `updated_at`

Local implementation can mirror existing `.repos/.meta` data, then migrate more
fully later.

### `sandbox_workspaces`

One row per durable worktree/workspace.

Recommended columns:

- `id`
- `repo_cache_id`
- `project_id`
- `user_id`
- `mode`
- `conversation_id`
- `task_id`
- `base_ref`
- `branch_name`
- `local_path`
- `state`
- `dirty`
- `pinned_until`
- `last_used_at`
- `size_bytes`
- `created_at`
- `updated_at`

Suggested uniqueness:

- Analysis workspace: `(user_id, project_id, base_ref, mode)` where
  `mode = "analysis"`.
- Edit workspace: `(user_id, project_id, conversation_id)` where
  `mode = "edit"`.
- Task workspace: `(user_id, project_id, task_id)` where `mode = "task"`.

### `sandbox_runtimes`

One row per runtime attachment.

Recommended columns:

- `id`
- `workspace_id`
- `backend_kind`
- `backend_runtime_id`
- `image`
- `state`
- `last_started_at`
- `last_used_at`
- `expires_at`
- `created_at`
- `updated_at`

Runtime records can be deleted aggressively. Workspace records should survive
runtime destruction.

---

## Workspace Lifecycle

### Analysis Flow

Use this for read-only code search and exploration.

1. Resolve `project_id` to repo details.
2. Ensure repo cache exists using `RepoManager.ensure_bare_repo` or
   `prepare_for_parsing`.
3. Get or create a clean worktree for the requested branch/commit.
4. Attach a read-only runtime when possible.
5. Run whitelisted read-only commands.

This can continue using `bash_command` plus `gvisor_runner.py` initially, but
the tool should treat gVisor fallback as reduced isolation and keep commands
read-only.

### Edit Flow

Use this for feature work, bug fixes, tests, and PR creation.

1. Resolve `project_id` to repo details.
2. Ensure repo cache exists.
3. Create or reuse an edit workspace keyed by `conversation_id`.
4. Create branch `agent/edits-{conversation_id}` from the project base branch.
5. Attach a write-capable runtime with the workspace mounted writable.
6. Run commands and edits inside that runtime.
7. Commit/push/PR from the same workspace.

This should replace the current split where generated changes live in Redis and
then get applied to the worktree. Redis can remain a UI/change-tracking cache,
but the workspace should become the source of truth for actual files.

### Task Flow

Use this for multi-agent or background work where one conversation may spawn
multiple independent implementation attempts.

1. Key workspace by `task_id`.
2. Create branch `agent/task-{task_id}` or a child branch from the edit branch.
3. Keep each task workspace isolated.
4. Merge/cherry-pick/diff back into the conversation edit workspace if accepted.

---

## Eviction Policy

Eviction should be tiered. Compute is cheap to recreate; repo clones are
expensive.

### Runtime Eviction

Default behavior:

- Stop idle runtimes after 15-30 minutes.
- Destroy stopped runtimes after 1-6 hours.
- Never delete the workspace as part of runtime eviction.

Triggers:

- Idle timeout.
- Backend quota pressure.
- Explicit user/project cleanup.
- Failed runtime health checks.

### Workspace Eviction

Default behavior:

- Keep edit workspaces for longer than runtimes, for example 7-30 days.
- Keep dirty or recently active workspaces longer.
- Never evict pinned workspaces.
- Prefer evicting clean analysis workspaces before dirty edit workspaces.

Suggested priority:

1. Clean analysis workspaces older than TTL.
2. Clean task workspaces older than TTL.
3. Clean edit workspaces older than TTL.
4. Dirty unpinned task workspaces under disk pressure.
5. Dirty unpinned edit workspaces only under severe disk pressure.

Before deleting a dirty workspace, consider one of:

- Create a patch artifact.
- Commit to an internal branch.
- Mark it as `eviction_blocked` and alert/admin-log.

### Repo Cache Eviction

Default behavior:

- Keep repo caches longest.
- Evict only when disk pressure remains after runtime and workspace eviction.
- Use LRU with size awareness.
- Do not evict a repo cache with active workspaces.

The existing `RepoManager` already has tiered eviction hooks:

- Worktree threshold: `_WORKTREE_EVICTION_THRESHOLD_PERCENTAGE`
- Repo threshold: `_REPO_EVICTION_TARGET_PERCENTAGE`
- Stale worktree age: `_STALE_WORKTREE_MAX_AGE_DAYS`

The sandbox service should reuse this behavior first, then move metadata into
Postgres when needed.

---

## Concurrency and Locking

Concurrency needs to be explicit because git worktrees and package managers are
not safe under arbitrary parallel mutation.

Use DB uniqueness plus advisory locks:

- Lock repo cache creation by normalized repo identity.
- Lock workspace creation by workspace uniqueness key.
- Lock mutating commands per workspace.
- Allow concurrent read-only commands when no mutating command is running.

Command classes:

- `read`: `rg`, `grep`, `cat`, `ls`, static inspection.
- `write`: file edits, `git checkout`, `git add`, `git commit`, formatters.
- `install`: package manager commands, dependency resolution.
- `test`: usually read-mostly but often writes build artifacts; treat as
  mutating unless mounted with a separate cache/output directory.

For the first implementation, a conservative per-workspace async lock around
all write-capable runtime commands is acceptable.

---

## Security Rules

- Do not pass host environment variables into runtimes by default.
- Inject only scoped credentials that the command actually needs.
- Keep git credentials out of remotes after clone/fetch.
- Do not mount host repo paths broadly; mount only the selected workspace.
- For write-capable work, do not silently fall back to host subprocess.
- Default network to `limited` or `none`; allow broader network only for
  explicit workflows like dependency install.
- Validate all file paths against the workspace root.
- Treat user-provided repo names, branches, and paths as untrusted input.

Important local-mode rule:

`gvisor_runner.py` is acceptable for the existing read-only analysis path. It
should not be used as the write-capable sandbox runtime because it mounts
read-only and has regular-subprocess fallback behavior.

---

## Adapter Responsibilities

| Adapter | Runtime backing | Workspace persistence | Good first use | Notes |
| --- | --- | --- | --- | --- |
| `LocalReadOnlyProvider` | existing gVisor runner | local worktree | read-only analysis | No write workflows; no silent trust in fallback |
| `DockerProvider` | long-lived container | local worktree or Docker volume | first write-capable backend | Best first implementation target |
| `DaytonaProvider` | Daytona workspace/runtime | native or synced workspace | managed sandbox backend | Use native snapshots/previews when useful |
| `E2BProvider` | Firecracker microVM | native or synced workspace | fast ephemeral runtime | Validate persistence semantics carefully |

Local Daytona development notes:

- One-shot setup: `app/src/sandbox/scripts/setup-daytona-local.sh` brings the
  Daytona compose stack up. The script layers
  `scripts/daytona-overrides/docker-compose.override.yaml` on top of the
  upstream compose so the dashboard host port is **3010** by default (port
  3000 is reserved for the potpie frontend); the override also mounts a dex
  config that whitelists `http://localhost:3010` as an OIDC redirect URI.
  Set `DAYTONA_DASHBOARD_PORT=...` before running the script to remap to
  another port.

  After the stack starts the script waits for `/api/health`, mints a dev API key for
  the bundled `dev@daytona.io / password` user, sets a default region if
  needed, writes `app/src/sandbox/.env.daytona.local`, and prints the URL of
  every observability dashboard the compose file already ships:
  - Daytona dashboard: `http://localhost:3010`
  - Sandbox snapshots: `http://localhost:3010/dashboard/snapshots`
  - Active sandboxes: `http://localhost:3010/dashboard/sandboxes`
  - Swagger API: `http://localhost:3010/api`
  - Jaeger traces: `http://localhost:16686`
  - pgAdmin: `http://localhost:5050`
  - Container registry UI: `http://localhost:5100`
  - MinIO console: `http://localhost:9001` (`minioadmin` / `minioadmin`)
  - MailDev: `http://localhost:1080`
- Uses the Daytona compose stack vendored in-repo at
  `app/src/sandbox/daytona/` by default (no external clone needed). To build
  Daytona images from source instead, point at a clone with
  `DAYTONA_REPO_PATH=/path/to/daytona`.
- Do not pass `--project-directory` to docker compose. Volume binds in the
  Daytona compose file are relative to the compose file's own directory and
  silently auto-create stub directories at the wrong location otherwise.
- Cleanup: `app/src/sandbox/scripts/teardown-daytona-local.sh` deletes only
  sandboxes labelled `managed-by=potpie`. Pass `--stack` to also bring the
  compose stack down with `-v`.
- The bundled `daytonaio/sandbox:0.5.0-slim` snapshot does **not** ship the
  `git` CLI. The Daytona adapter handles this by using the toolbox `git.clone`
  / `git.create_branch` / `git.checkout_branch` endpoints and verifying
  outcomes by reading `.git/HEAD` directly (the toolbox SDK occasionally
  raises `DaytonaValidationError("...: ")` on operations that actually
  succeeded).
- Tests resolve `proxy.localhost` to 127.0.0.1 in-process (see
  `tests/e2e/conftest.py`) so neither sudo nor the `setup-proxy-dns.sh`
  dnsmasq script is required for E2E runs. Long-lived shell access still
  needs the dnsmasq setup or an `/etc/hosts` entry.
- Adapter env vars (sourced from `.env.daytona.local`):
  `SANDBOX_WORKSPACE_PROVIDER=daytona`, `SANDBOX_RUNTIME_PROVIDER=daytona`,
  `DAYTONA_API_URL`, `DAYTONA_API_KEY`, optional `DAYTONA_SNAPSHOT`,
  `DAYTONA_WORKSPACE_ROOT`.

The adapter must:

- Translate runtime lifecycle and exec calls.
- Map backend errors into typed sandbox errors.
- Enforce its declared mount/network behavior.
- Report capabilities truthfully.

The adapter must not:

- Decide which user/repo/workspace should be used.
- Own the clone-on-create policy.
- Reach into Postgres workspace mappings directly.

---

## Typed Errors

Every adapter should map backend-specific failures into a small error set.

```python
class SandboxError(Exception): ...
class SandboxNotFound(SandboxError): ...
class SandboxUnauthorized(SandboxError): ...
class SandboxTimeout(SandboxError): ...
class SandboxConflict(SandboxError): ...
class SandboxUnavailable(SandboxError): ...
class SandboxResourceLimit(SandboxError): ...
class SandboxCommandRejected(SandboxError): ...
```

Service-level repo/workspace errors should be separate:

```python
class WorkspaceError(Exception): ...
class WorkspaceNotFound(WorkspaceError): ...
class WorkspaceLocked(WorkspaceError): ...
class WorkspaceDirty(WorkspaceError): ...
class RepoCacheUnavailable(WorkspaceError): ...
class RepoAuthFailed(WorkspaceError): ...
```

---

## Implementation Roadmap

The original Phase 1-5 plan is **complete** — the sandbox module, the
runtime port, the Daytona adapter, the agent tool surface, and the
Postgres-ready store are all shipped. The post-revamp phases (P1-P8)
each address a numbered gap from the audit. Status as of this
session:

### P1 — Unify the local provider — DONE

Closes gaps 1, 2, 3.

* `EvictionPolicy` port at `domain/ports/eviction.py` with
  `EvictionResult` value type; `NoOpEvictionPolicy` default in
  `adapters/outbound/memory/eviction.py`.
* `LocalGitWorkspaceProvider` accepts `eviction=` kwarg, calls
  `evict_if_needed` on cache miss.
* `LocalGitWorkspaceProvider` is now the only local provider. The
  `RepoManagerWorkspaceProvider` bridge and the
  `SANDBOX_USE_CANONICAL_LOCAL` flag have been removed; sandbox traffic
  goes through the canonical adapter unconditionally.
* **Operator note:** any on-disk worktrees created by the old bridge
  (layout: `<branch>` for shared, `<user>_<unique>_<branch>` for
  conversation-scoped) are not visible to the canonical adapter. New
  conversations get fresh worktrees in the `<user>_<scope>_<branch>`
  layout; commit/push any in-flight bridge worktree state before
  upgrading.
* Volume-aware policy (`VolumeBasedEvictionPolicy`) replacing the
  `NoOp` default is still a P1 follow-up; the canonical path runs
  unbounded until the policy is wired.

### P2 — Promote `RepoCache` to first-class — DONE

Closes gap 5.

* `RepoCacheRequest` and `RepoCache` (with stable `key`) in
  `domain/models.py`.
* `RepoCacheProvider` port at `domain/ports/repos.py`.
* `RepoCacheStore` mixin on `SandboxStore`; both `InMemorySandboxStore`
  and `JsonSandboxStore` implement it (rows survive restart).
* `LocalRepoCacheProvider` at
  `adapters/outbound/local/repo_cache.py` owns bare-repo creation;
  `LocalGitWorkspaceProvider` depends on the cache port and sets
  `Workspace.repo_cache_id` on every workspace it builds.
* `SandboxService.ensure_repo_cache(request)` keys by repo identity,
  takes a per-key lock, persists the row.

### P3 — Capabilities split and `acquire_session` API — DONE

Closes gaps 6 and 10.

* `Capabilities(writable, isolated, persistent)` value object;
  `Capabilities.from_mode` is the single source of truth.
* `Workspace.capabilities` populated by every adapter (local, bridge,
  Daytona). Round-trips through `JsonSandboxStore`.
* `SandboxService.acquire_session(request)` orchestrates ensure-cache
  + workspace creation atomically.
* `SandboxService.release_session(workspace_id, *, destroy_runtime)`
  hibernates the runtime by default; workspace survives.
* `SandboxClient.acquire_session` / `release_session` symmetric public
  API.

### P4 — Daytona hardening — partial

Addresses gap 7 (correctness portion).

**Done:**
* `_validate_ref` in `daytona/provider.py` rejects newlines / `..` in
  base_ref and branch_name before they hit shell-style exec calls
  (parity with the local adapter).
* Worktree path now `<user>_<scope>_<branch>` so two conversations on
  the same branch get distinct worktrees (no silent collision).

**Deferred (code-quality follow-up):** The Daytona SDK still types as
`Any` on `_client_factory`/`_sandboxes`; the full `DaytonaApi` Protocol
abstraction is a 500+ line refactor that doesn't fix any correctness
issue. Track as a P4.5 cleanup.

### P5 — Provision-on-parse — DONE

Closes gap 4.

* `SandboxClient.ensure_repo_cache(...)` thin wrapper over the service.
* `provision_repo_cache` helper at
  `app/modules/intelligence/tools/sandbox/client.py`.
* `parsing_service.py` calls `_provision_repo_cache_safe` from BOTH
  READY transitions (eager-return short-circuit and the normal
  post-`analyze_directory` exit). Failures are logged and swallowed —
  cache provisioning is an optimization, not a parsing prerequisite.

### P6 — Tool surface refactor — DONE

Closes gap 9.

* `create_sandbox_tools(client=..., handle=...)` — explicit-handle
  factory mode. Tools dispatch through pre-bound `(client, handle)`
  closures with input schemas that omit `project_id`. Capability
  gating drops write tools when the handle is read-only
  (`enforce_capabilities=True` default).
* The legacy zero-arg `create_sandbox_tools()` form keeps working —
  required for back-compat with the existing harness wiring at
  `multi_agent/agent_factory.py` and `pydantic_agent.py`.
* Helper functions extracted in `tool_functions.py`:
  `_exec_text_editor`, `_exec_shell`, `_exec_search`, `_exec_git`,
  `_exec_pull_request`. Both factory modes funnel through the same
  helpers.
* `WorkspaceHandle.capabilities` carries the gating signal.
* Contextvar machinery in `tools/sandbox/context.py` is **kept** for
  the legacy form — full removal happens once harness callers migrate
  to the explicit form (P6.5 cleanup).

### P7 — `GitPlatformProvider` and PR tool — DONE

Closes gap 8.

* `GitPlatformProvider` port at `domain/ports/git_platform.py`;
  `PullRequestRequest` / `PullRequest` value objects in
  `domain/models.py`; `PullRequestFailed` /
  `GitPlatformNotConfigured` errors.
* `GitHubGitPlatformProvider` bridge adapter at
  `app/modules/sandbox_repos/git_platform.py` wraps the existing
  `code_provider.github.GitHubProvider` so auth chain stays put.
* `SandboxService.create_pull_request(request)` enforces "platform
  configured" precondition; `SandboxClient.create_pull_request(handle,
  ...)` enforces "writable workspace" precondition.
* `sandbox_pr` agent tool — included in the explicit toolset only when
  the harness passes ``pr_repo_name=...``; capability-gated on
  writable handles. Push the branch via `sandbox_git push` first; the
  PR tool is the platform-side step only.
* **Production wiring of the platform provider is a follow-up.** The
  per-call user resolution (auth tokens scoped to the calling user)
  doesn't fit the process-wide `SandboxClient` cleanly; needs a small
  request-scoped factory before it can run in prod.

### P8 — Postgres store and multi-worker locks — DEFERRED

Closes the implicit single-node assumption in `JsonSandboxStore` and
`InMemoryLockManager`. The schema sketch is already documented above
(`sandbox_repo_caches`, `sandbox_workspaces`, `sandbox_runtimes`).
Implementation requires a Postgres connection and migration tooling
that aren't in this session's scope. Adapter signatures will mirror
the existing in-memory and JSON ones, so the swap is a bootstrap-only
change.

**Track as a follow-up.** The current `JsonSandboxStore` is suitable
for single-node deployments; multi-worker / multi-host setups need the
Postgres adapter, since the JSON store's flush model assumes a single
writer.

### Outstanding follow-ups (small)

* P1 follow-up — `VolumeBasedEvictionPolicy` reading from
  `SandboxStore` so canonical-local has bounded disk use.
* P4 follow-up — `DaytonaApi` Protocol port to remove `Any` typing.
* P6 follow-up — migrate harness callers
  (`multi_agent/agent_factory.py`, `pydantic_agent.py`) to the
  explicit-handle form, then delete the contextvar plumbing.
* P7 follow-up — request-scoped `GitPlatformProvider` factory so the
  per-user auth chain works in production.
* P8 — Postgres adapters once the DB story lands.

---

## Open Questions

- Should edit workspace persistence be keyed by `conversation_id`, by an
  explicit "agent task/session id", or both?
- When a dirty workspace is old enough to evict, should Potpie auto-commit to
  an internal branch, save a patch artifact, or block eviction?
- Should dependency caches be per-user, per-repo, or per-workspace?
- How much network access should test/install commands get by default?
- Should local Docker workspaces mount host worktrees directly, or should they
  copy/sync into a Docker volume for stronger host isolation?

---

### Sandbox Integration Plan (`docs/sandbox-integration-plan.md`)

---
title: Sandbox integration plan
description: Plan to make sandbox the single library for repo materialization and command execution.
---

## Overview

This document plans the next phase of `app/src/sandbox`: turning it into the
single library that everything else in potpie uses to materialize repos and run
code, and retiring the `app/modules/repo_manager` + `app/modules/intelligence/
tools/code_changes_manager` stack.

It is a plan, not a spec. Where there are real tradeoffs the doc names them
and points at the surfaces involved, but leaves the call to the implementer.
Read [Sandbox core setup](./sandbox-core-setup.md) first — this builds on the model defined
there (`RepoCache` / `Workspace` / `Runtime`).

---

## 1. Where we are

The sandbox module today (`app/src/sandbox/sandbox/`) is hexagonal and runnable:

- Domain: `Workspace`, `Runtime`, `RepoIdentity`, `WorkspaceRequest`,
  `ExecRequest`, etc. (`domain/models.py`).
- Ports: `WorkspaceProvider`, `RuntimeProvider`, `SandboxStore`, `LockManager`
  (`domain/ports/*`).
- Adapters: local-git workspace, local-subprocess runtime, docker runtime,
  daytona workspace + runtime, file/json store, in-memory locks
  (`adapters/outbound/*`).
- Application service: `SandboxService` (`application/services/sandbox_service.py`)
  exposes `get_or_create_workspace`, `get_or_create_runtime`, `exec`,
  `hibernate_runtime`, `destroy_runtime`, `destroy_workspace`. It is
  idempotent, locks per-workspace, and tracks `dirty` after writes.
- E2E tests cover all three runtime backends end-to-end (subprocess, docker,
  daytona). Daytona dev stack is bootstrapped via `scripts/setup-daytona-local.sh`
  and the override compose file.

The rest of potpie still drives a parallel stack:

- `app/modules/repo_manager/` — bare repo + worktree management on disk under
  `.repos/<owner>/<repo>/`. Has its own auth chain, eviction, metadata.
- `app/modules/intelligence/tools/code_changes_manager/` — Redis-backed
  per-conversation file-edit staging area, with ~20 LangChain
  `StructuredTool`s wrapped around it (`add_file_to_changes`,
  `update_file_lines`, `replace_in_file`, …, plus `git_commit`, `git_push`,
  `bash_command`).
- Parsing (`app/modules/parsing/`) calls `RepoManager.prepare_for_parsing()` to
  get a worktree path and walks it directly with `os.walk`/Tree-sitter.
- Agents (PydanticAI in `app/modules/intelligence/agents/`) get tools via
  `ToolService` + `ToolResolver`, with `ChatContext` (`project_id`, `user_id`,
  `conversation_id`, `branch`, `local_mode`) carrying scope.

The two stacks duplicate concepts: both clone, both manage worktrees, both
think about branches. The plan unifies them under the sandbox module.

---

## 2. Goals

- A small, library-shaped public API on `app/src/sandbox` that the rest of
  potpie can import and use to: get a sandbox, get a working tree on a branch,
  read/write files, run commands, search, commit, push.
- **Repo lifecycle owned by sandbox.** The caller passes
  `(repo, branch, user, project)` — sandbox does the cloning, the worktree
  creation, the branch switching, and (eventually) the eviction. Parsing and
  agents both go through it.
- **Pre-provisioned tooling.** Common dev tools (`rg`, `git`, `fd`, `jq`,
  `python`, `node`) are present in the runtime out of the box, so agent tools
  don't need to detect/install at runtime.
- **One backend sandbox, many branches.** A single backend container per
  `(user, project)` hosts multiple worktrees so multiple agent runs on
  different branches share the same expensive thing (the Daytona sandbox /
  Docker container) without serializing.
- **Sandbox-backed agent tools** ship from the sandbox library itself,
  registered into `ToolService` like any other tool. Each tool resolves its
  workspace from the agent's run context.
- **`CodeChangesManager` is gone.** Edits go straight to the worktree (which
  is durable git state). The Redis-staged-changes model is replaced by
  "edit the file, commit when done." If we want stage-and-review semantics
  later they live above the sandbox, not inside it.
- **`.repos` becomes one adapter** under the sandbox `WorkspaceProvider`
  port, used for local development and tests. Daytona is the production path.

## Non-goals

- Replacing the agent framework, the LLM provider abstraction, or the search
  service. The sandbox owns "where the bits live" — not "how the agent
  thinks."
- Full distributed locking. The `InMemoryLockManager` is fine for the single-
  worker case; a Redis-backed lock manager is a separable later concern.
- Snapshot-based hibernation as a primary persistence mechanism. Git worktrees
  + the backend's volume are the persistence.
- Backwards compatibility with the `code_changes_manager` Redis schema. We are
  removing it; there's no migration.

---

## 3. Conceptual additions

The current sandbox model (one `Workspace` = one branch) is right for what it
solves, but the integration goals push two extensions:

### 3.1 RepoCache becomes load-bearing

`RepoCache` exists in `domain/models.py` (lines 116–127) but isn't wired in.
The plan promotes it to a real concept owned by the workspace provider:

- `RepoCache` = "the bare clone, somewhere a backend can reach it."
- `Workspace` = "a working tree on top of a `RepoCache`, scoped to a branch."
- One `RepoCache` per `(user, repo)` — or per `(repo)` if we trust shared
  cache semantics; defer that call until we actually share across users.
- Many `Workspace` per `RepoCache`, one per active branch.

In adapter terms:

| Backend          | RepoCache materialization                   | Workspace materialization              |
| ---------------- | ------------------------------------------- | -------------------------------------- |
| Local git        | `.repos/<owner>/<repo>/.bare/`              | `.repos/.../worktrees/<key>/`          |
| Daytona          | Bare clone *inside* a long-lived sandbox    | Worktree in the same sandbox           |
| Docker           | Bare clone in a named docker volume         | Worktree mounted into the runtime      |

The Daytona row is the interesting one: today each `WorkspaceRequest` creates
its own Daytona sandbox. The new model creates one Daytona sandbox per
`(user, project)` and uses `git worktree` *inside* it for each branch. That's
how we get multi-branch concurrency without paying for N sandboxes.

The implementer should decide whether to:
- (a) Add a `RepoCacheProvider` port alongside `WorkspaceProvider`, or
- (b) Fold the cache semantics into `WorkspaceProvider` (one provider, two
  conceptual layers), or
- (c) Keep `RepoCache` purely as a value object that providers populate
  internally.

(b) or (c) is probably right — adding a port costs more than it pays for
unless we expect to swap cache backends independently.

### 3.2 Workspace as a branch handle

A `Workspace` already has `metadata["branch"]` and a worktree path. The plan
keeps that, but tightens the lifecycle:

- Creation: clone or fetch into the cache, then `git worktree add` for the
  branch.
- Switch-branch: not actually a switch — it's "give me a different
  `Workspace` on the same `RepoCache`." The caller asks for a workspace by
  `(user, project, branch)` and gets the right worktree.
- Reuse: the existing key-based idempotency in `SandboxService.get_or_create_
  workspace` already does this (line 50). Verify the key formula handles the
  `(repo, branch)` case cleanly without `conversation_id` or `task_id`
  required.
- Cleanup: `destroy_workspace` removes the worktree but keeps the cache.
  A separate path destroys the cache (or the whole backend container).

This is the biggest behavioral change. Today the daytona adapter
(`adapters/outbound/daytona/provider.py:131-152`) calls
`daytona.create(...)` per workspace. After the change, sandbox creation is
keyed on `(user, project)` and worktree creation runs *inside* an existing
sandbox.

---

## 4. The public client API

What the rest of potpie should be able to import. Treat names as suggestions.

### 4.1 The package surface

```
app/src/sandbox/sandbox/__init__.py
    # The library export
    SandboxClient
    SandboxClientConfig
    # Re-exports of the small set of types callers actually need:
    WorkspaceHandle, ExecResult, NetworkMode, CommandKind,
    SandboxError, WorkspaceNotFound, ...
```

`SandboxClient` is a thin façade over `SandboxService` plus the bootstrap
container. The intent: callers don't construct providers, stores, locks, or
build runtime specs by hand. They get a client and ask for what they need.

Sketch — flesh out in code:

```
/* Detailed source-code truncated for AI context efficiency. */
```

### 4.2 What `WorkspaceHandle` is

A small, opaque object containing the IDs the client needs to talk to the
service: `workspace_id`, the resolved `branch`, the runtime working dir.
*Not* the `Workspace` domain object — the handle is the stable thing returned
to callers. Internally the client looks up the live `Workspace` each call.

Why opaque: it lets the implementer change what's stored (a single ID vs a
small struct vs a `Workspace` snapshot) without churning every caller.

### 4.3 Read/write helpers vs `exec`

The existing `exec()` is enough to do everything, but agent tools end up
shelling out for trivial operations and re-implementing argument quoting.
The helpers (`read_file`, `write_file`, `list_dir`, `search`) are first-class
on the client because:

- They have a fixed surface that's easy to mock / fake in tests.
- They can dispatch to backend-native APIs when available (Daytona toolbox
  has `fs.upload_file`, `fs.download_file`, `git.*` — currently only used by
  the daytona workspace provider).
- They give the agent prompt a small, named tool surface (see §7).

The implementer should look at Daytona's toolbox API
(`adapters/outbound/daytona/provider.py`, lines 154–228 are precedent) and
wire the helpers through `RuntimeProvider` so each backend has one chance
to do it natively before falling back to `exec`.

### 4.4 What we deliberately don't expose (yet)

- No `Runtime` lifecycle on the client. Runtime is implicit; `exec` brings
  it up. `hibernate` / `destroy` show up as `release_workspace`.
- No streaming exec. `exec_stream` exists in the port but the inbound
  surface stays sync-result for v1; revisit when an agent flow actually
  benefits.
- No "preview URL" / port-forwarding. `RuntimeCapabilities` already models
  this; expose it when there's a caller.

---

## 5. Preinstalled tooling

Today the runtime images are minimal: `python:3.12-slim` for docker default,
`daytonaio/sandbox:0.5.0-slim` for daytona, host PATH for subprocess.
Nothing has `rg` (ripgrep), and the Daytona slim image doesn't even ship git
CLI — the daytona adapter explicitly works around that with the toolbox API
(see `provider.py:182-185`).

The plan: ship our own image for the docker and daytona backends with a
known-good toolset.

### 5.1 Image contents (proposal)

A single Dockerfile in `app/src/sandbox/images/agent-sandbox/` produces
`potpie/agent-sandbox:<version>`:

- Base: `python:3.12-slim` (or `debian:stable-slim` if startup time is an
  issue and we want a cheaper base).
- Tools: `git`, `git-lfs`, `ripgrep`, `fd-find`, `jq`, `curl`, `ca-certificates`,
  `tini`, `tree`, `less`, `coreutils`, `procps`, `openssh-client`.
- Runtimes: Python (with `pip`, `uv` optional), Node LTS, GitHub CLI (`gh`)
  if we want it for PR creation.
- A non-root user `agent` with $HOME persistent — the worktree will live
  under `/home/agent/work/`.
- An entrypoint that pre-creates the worktrees dir and exec's the requested
  command (mirrors what the override compose entrypoint does today).

The implementer should check the actual size — going from slim+toolbox to
full-fat is a real tradeoff for Daytona cold-start. If that's painful, split:
a "lean" image without Node and a "full" image with everything; pick per
workload.

### 5.2 Daytona snapshot

Daytona consumes images via "snapshots." The override compose stack already
has a flow for this (`scripts/setup-daytona-local.sh`). Process:

1. Build the image.
2. Push to a registry Daytona can pull from. (For dev: the local docker daemon.)
3. Run `daytona snapshot create potpie/agent-sandbox:<version>`
   (or however the Daytona SDK phrases it — check existing scripts).
4. Default `DAYTONA_SNAPSHOT` env var to the new snapshot in `.env.daytona.local`
   and in `bootstrap/settings.py`.

Tests: extend `tests/e2e/test_daytona_e2e.py` with a sanity check that
`rg --version` and `git --version` succeed inside a fresh sandbox.

### 5.3 Subprocess backend

The local subprocess backend is purely for tests and dev-machine debugging.
It uses host PATH. We don't ship anything; we document that `rg` should be
on PATH. Tests already skip when not available.

---

## 6. Repo lifecycle in the new model

### 6.1 Parsing path

Today `ParseHelper.clone_or_copy_repository` (`app/modules/parsing/`)
calls `RepoManager.prepare_for_parsing` and walks the resulting path with
`os.walk`. Tomorrow:

```
ParsingService.parse_directory(repo_details, ...)
  → SandboxClient.get_workspace(
        user_id, project_id, repo, branch=base_ref,
        mode=WorkspaceMode.ANALYSIS, create_branch=False,
    )
  → handle = ...
  → run parsing against handle (see below)
  → SandboxClient.release_workspace(handle)   # or keep for re-parse
```

How parsing actually reads files matters:

- **Option A (recommended, easier):** parsing keeps running on the host;
  the sandbox client just produces a host-readable path. For the local-fs
  backend this is the existing `.repos/.../worktrees/...`. For Daytona,
  this means: don't run parsing in Daytona — run the cheap clone in
  Daytona only when the agent actually needs a sandbox, and use the
  local-fs backend for parsing. Two backends, one client API, picked per
  workload.

- **Option B (harder):** parsing runs *inside* the sandbox (rust extractor
  baked into the image, results streamed back). The agent (`Map parsing
  flow & repo dependency` writeup, §10) flagged GitPython fork-safety,
  Rust backend distribution, and embedding model size as the costs. Worth
  doing if we want a uniform model for managed-cloud customers, but not
  the v1.

Pick A. Make sure the client's helpers don't accidentally tie callers to
"the path is on the host." The `WorkspaceHandle` should expose a
`local_path: str | None` field that is populated only for local-fs backends
and `None` for daytona — parsing checks for it and skips Daytona for now,
or refuses with a clear error.

### 6.2 Agent path

Today: agent tool fires, calls `RepoManager` to get a worktree, writes
through `CodeChangesManager` to Redis, later commits.
Tomorrow:

```
ChatContext { project_id, user_id, conversation_id, branch, ... }
  → SandboxClient.get_workspace(
        user_id, project_id, repo, branch=branch,
        mode=WorkspaceMode.EDIT, create_branch=True,
    )
  → handle is cached on the agent run (see §7.3 for where)
  → tools call client.{read_file, write_file, search, exec, commit, ...}
  → run ends → release_workspace (don't destroy the worktree yet — keep it
    until conversation cleanup).
```

The branch model: `WorkspaceMode.EDIT` already names branches
`agent/edits-{conversation_id}` (`adapters/outbound/local/git_workspace.py`,
lines 204-211). Keep that. For the daytona backend the branch lives as a
worktree on a shared sandbox per `(user, project)`.

### 6.3 Switching branches

User asked for "checkout somehow when plugging to the agent." Two
interpretations:

- (a) Same conversation, switch branch — operator wants to abandon the
  current branch and resume on another. Express as: release the old
  workspace, get a new workspace with a different branch.

- (b) Same backend container, multiple branches in flight — multiple
  conversations / agents, one Daytona sandbox. Express as: each
  `get_workspace` returns a new worktree inside the existing sandbox.

(b) is the real concurrency story; (a) is a special case of "release +
get_or_create." The implementer shouldn't add a `switch_branch` operation
on the client — it's just two existing calls.

### 6.4 Cleanup

Three layers:

1. Worktree (cheap): destroy on conversation close, or after N days idle.
2. Backend sandbox (expensive in $$$): destroy on user inactivity, or per
   organization quota. Hibernate (Daytona auto-stops after 30s; auto-archives
   after 12h — already configured in
   `adapters/outbound/daytona/provider.py:142`).
3. Repo cache (expensive in disk): LRU evict, or per-repo TTL.

Today's `RepoManager._evict_if_needed` (the writeup §3 in the
code-changes-manager map) is a reasonable starting point. Port it as a
*background task* that the local-fs adapter runs, not as inline-during-
clone behavior — that surprised people. Daytona handles its own GC for
sandboxes; we only need to run cache cleanup if we end up storing caches
in named volumes.

---

## 7. Agent tool exports

The user wants a set of tools they can plug into agents that gives the
agent access to a specific `(repo, branch)` sandbox.

### 7.1 Tool catalog (proposal)

Ship a small, opinionated set. Names are starting points — match the
existing tool naming style (snake_case, verbs). Each tool's input schema
includes `project_id` (the agent already passes this) and the input gets
resolved to a `WorkspaceHandle` at call time.

| Tool                       | Wraps                                       | Agent uses for                      |
| -------------------------- | ------------------------------------------- | ----------------------------------- |
| `sandbox_read_file`        | `client.read_file`                          | view a file (line numbers optional) |
| `sandbox_write_file`       | `client.write_file`                         | full-file replace                   |
| `sandbox_str_replace`      | `client.read_file` + write                  | targeted in-file edit               |
| `sandbox_list_dir`         | `client.list_dir`                           | navigate                            |
| `sandbox_search`           | `client.search` (ripgrep)                   | grep across the tree                |
| `sandbox_run`              | `client.exec`                               | run any command (whitelisted?)      |
| `sandbox_run_tests`        | `client.exec` + framework detection         | language-aware test runner          |
| `sandbox_git_status`       | `client.status`                             | what changed                        |
| `sandbox_git_diff`         | `client.diff`                               | review changes                      |
| `sandbox_git_commit`       | `client.commit`                             | commit                              |
| `sandbox_git_push`         | `client.push`                               | push (auth-token aware)             |
| `sandbox_open_pr`          | provider-specific PR creation               | end of feature flow                 |

The implementer should compare to today's `code_changes_manager` tools
(20-ish tools across staging operations) and consciously drop the ones
that exist only because edits were staged in Redis — `clear_file`,
`get_changes_summary`, `serialize`, `revert_file`, etc. The agent operates
directly on the worktree; Git is the source of truth and the audit log.

### 7.2 Where the tool code lives

Two reasonable placements:

- **Inside the sandbox library**, exported as factory functions. Tools
  import only from the public client API. This is cleanest — the sandbox
  library is self-contained.
- **In `app/modules/intelligence/tools/sandbox/`**, depending only on
  the sandbox client. This matches the rest of the tool layout.

Pick the second. Sandbox stays a pure library; potpie-specific glue
(StructuredTool wrapping, ToolService registration, ChatContext binding)
stays where the rest of that glue lives. The library only exports
data-shaped helpers, not LangChain `StructuredTool`s.

### 7.3 How tools resolve the workspace

The tool needs to know `(user_id, project_id, repo, branch)` to call
`client.get_workspace`. Today `ChatContext` already carries all four
(`app/modules/intelligence/agents/chat_agent.py:71-162`). Two viable
approaches:

- **Closure capture**: `ToolService.__init__` (or a new
  `SandboxToolFactory`) constructs each tool with the live `ChatContext`
  for the run. Tools don't take `project_id` in their args. Pros: agent
  can't "lie" about which project. Cons: tool factories need to be
  re-instantiated per agent run, which doesn't match the current
  `ToolService` lifecycle (per-user singleton).

- **Explicit args**: tool input schema keeps `project_id` (matching all
  current tools — see `code_query_tools/bash_command_tool.py:455-525`).
  The tool resolves it on each call. The branch is discovered server-side
  from `ChatContext` rather than the LLM picking. Pros: fits the
  existing pattern. Cons: agent could in theory pass a different
  project_id, but `ProjectService.get_project_from_db_by_id_sync` already
  validates user-project ownership.

Pick **explicit args** for parity. Branch comes from `ChatContext` via a
mechanism the implementer can mirror from `bash_command_tool` (which uses
`get_or_create_edits_worktree_path` and relies on the conversation context).
For sandbox tools, that translates to: a small `SandboxRunContext`
contextvar set at the top of every agent run with the resolved
`WorkspaceHandle` (or the data needed to fetch it), and tools read it.

### 7.4 Result shape

Match the existing tools' shape: `Dict[str, Any]` with `success`, the
result data, and explicit truncation flags. `bash_command_tool`'s 80k-char
output limit is a precedent — keep similar for `sandbox_run`. For
`sandbox_search` return a list of hits with `file:line:snippet`; that's
what the LLM actually wants.

### 7.5 Allow-list and registry

Add a `sandbox` tool group in
`app/modules/intelligence/tools/registry/definitions.py` and update the
agent allow-lists (`code_gen`, `execute`, `qna` etc. — see
`agents/chat_agents/system_agents/`). The registry-driven path
(`ToolResolver.get_tools_for_agent`) is the preferred entry point;
hardcoded allow-lists should be migrated as part of this same change
since they refer to soon-to-be-removed tool names.

---

## 8. Concurrency model

The combined goal — multiple agents on different branches — needs three
things to be true:

1. **One backend container per `(user, project)`**, shared across
   conversations. Lock key for "create or attach": `repo-cache:{user_id}:
   {project_id}` (or whatever `RepoCache.key()` ends up being). Held only
   for the create critical section.

2. **Per-worktree write isolation.** Today's lock key
   `workspace-command:{workspace_id}` is right (
   `application/services/sandbox_service.py:91-96`) — keep it. Two agents
   on two branches don't share a lock; two agents *in the same
   conversation* serialize.

3. **No process-wide singletons.** `SandboxClient` is per-(potpie process)
   and stateless except for the underlying provider/store/locks. Multiple
   Celery workers each hold their own client; they coordinate through the
   store (which becomes interesting — see below).

### 8.1 Store: in-memory vs durable

`InMemorySandboxStore` and `JsonSandboxStore` work fine for one process.
Multiple Celery workers need something durable + concurrent: a Postgres
table is the path of least resistance (we already have the DB session).
Add a `PostgresSandboxStore` adapter; use the same `SandboxStore` port
unchanged. The implementer should look at the existing context-graph pot
migrations (the recent `20260407_*` migration) for precedent on how
sandbox-related schema gets shipped.

### 8.2 Locks: in-memory vs Redis

Same shape. `InMemoryLockManager` is fine for tests and single-worker.
Production wants `RedisLockManager` (we have Redis; the
`code_changes_manager`'s storage layer already uses it). Same `LockManager`
port — drop in.

The implementer should not block the v1 plan on these; ship the Postgres
store + Redis lock as a follow-up if a single-worker rollout is enough
for the first migration step.

### 8.3 Cancellation

`ChatContext.check_cancelled` is the current escape hatch. Sandbox tools
that call long-running `exec` should poll it (or pass a timeout that
respects it). `SandboxClient.exec` should accept an optional
`cancellation_token` callable.

---

## 9. Migration: removing `CodeChangesManager` and `.repos` glue

The two systems are intertwined. Order:

1. **Stand up the new client surface.** Build `SandboxClient` over the
   existing service, helpers and all. Local-fs backend is enough.
2. **Build the new sandbox tools** (§7) and register them under their own
   names. Don't delete the old tools yet.
3. **Move parsing.** `ParseHelper.clone_or_copy_repository` is the only
   place outside agents that touches `RepoManager`. Replace its
   `RepoManager` calls with `SandboxClient.get_workspace` (using the
   local-fs backend, ANALYSIS mode). Tests in
   `app/modules/parsing/tests/` should still pass.
4. **Migrate one agent** end-to-end — start with `code_gen_agent` since
   it's the most-touched. Replace its hardcoded tool list with the new
   sandbox tool names; update its system prompt. Run the existing
   end-to-end smoke tests.
5. **Delete `CodeChangesManager`.** Once `code_gen_agent` is on the new
   tools, the staging tools (`add_file_to_changes`, `update_file_lines`,
   `replace_in_file`, etc.) have no callers. Delete the package, the
   Redis schema, the `_init_code_changes_manager` plumbing in
   `execution_flows.py`, and the imports that scatter from there.
6. **Make `.repos` an adapter.** At this point `RepoManager` still exists
   as an implementation detail of the local-fs `WorkspaceProvider`.
   Refactor: move the bare-repo + worktree code from
   `app/modules/repo_manager/` into
   `app/src/sandbox/sandbox/adapters/outbound/local/repo_cache.py`
   (or similar), keeping the auth chain (`sync_helper.py`'s GitHub App →
   OAuth → env-token logic) since it's hard-won. The shape of
   `LocalGitWorkspaceProvider` doesn't change much — it just absorbs
   `RepoManager`'s functionality.
7. **Delete `app/modules/repo_manager/`.** The legacy module's only job
   was to be that adapter; now the sandbox owns it.

Each step ships independently. Steps 1–4 are additive; 5–7 are deletions.
Don't intermix.

### 9.1 What to do with the staging idea

`CodeChangesManager` had a useful property: edits were reviewable before
commit. Without it, agent edits land directly in a worktree branch. That's
*also* reviewable (it's a git branch you can diff against base), and
arguably better — diffing a branch is a tool the LLM and humans both
already understand.

If we miss "show the user a pending-changes summary," that's a UI concern
on top of `git diff`, not a sandbox concern. Don't put it back in.

### 9.2 Apply-changes flow

Current flow: `apply_changes` tool reads from `CodeChangesManager` Redis
and writes files into the worktree. New flow: there is no `apply_changes`
— each `sandbox_write_file` / `sandbox_str_replace` writes directly. The
`git_commit` tool (which is now `sandbox_git_commit`) commits whatever is
staged. There's nothing to "apply."

### 9.3 Local mode (VS Code extension)

`local_mode` in `ChatContext` switches several tools to talk to a local
tunnel instead of the worktree. The plan: keep the local-mode path in
the *agent* layer, not the *sandbox* layer. When `local_mode` is on,
the agent uses a different tool set (the existing tunnel-backed tools)
and bypasses `SandboxClient` entirely. The sandbox library doesn't need
to know about VS Code.

### 9.4 Auth token handling

`WorkspaceRequest.auth_token` is already plumbed (the daytona adapter
embeds it via the `#__potpie_token__=...` marker; the local adapter
embeds it as `x-access-token:...@host`). Tokens are never persisted
(`adapters/outbound/file/json_store.py:125`). Keep that property — the
`SandboxClient` API takes the token at workspace creation, and the rest
of the system never sees it again. The implementer should keep the
auth-chain code (`sync_helper.py`) intact when it moves into the
adapter, since GitHub App → OAuth → env-token is more than the sandbox
should re-derive.

---

## 10. `.repos` as an adapter

After §9.6 the local-fs adapter lives at
`adapters/outbound/local/`. Concretely:

- `repo_cache.py`: bare-repo lifecycle (clone, fetch, GC, eviction).
  Absorbs the meaningful parts of `repo_manager.py`.
- `git_workspace.py` (existing, expanded): worktree lifecycle on top of
  the cache.
- `subprocess_runtime.py` (existing): host execution.
- `auth.py`: GitHub App → OAuth → env-token chain. Absorbs
  `sync_helper.py`'s logic.

Public behavior the local adapter must preserve:

- `.repos/<owner>/<repo>/.bare/`, `.../worktrees/<name>/` paths — for
  developer ergonomics and for the existing parsing flow which knows
  these paths.
- `REPOS_BASE_PATH` env var — keep it as the config knob.
- `GH_TOKEN`, `GH_TOKEN_LIST`, `GITHUB_BASE_URL` env vars — keep them.
- The existing eviction policy — port it; don't re-design as part of this.

Things the local adapter may stop doing (because the sandbox layer
above it now does them):

- The `.meta/<owner>/<repo>/branch__commit.json` files. The
  `SandboxStore` is now the source of truth for branch/commit/age
  metadata. Migrate the data into the store on first read; delete the
  files on write.

---

## 11. Prompt updates

System prompts in `app/modules/intelligence/agents/chat_agents/system_
agents/code_gen_agent.py` and the generic `pydantic_agent.py` reference
the old tool names extensively (lines 30–122 and 413–650+ in
`code_gen_agent.py`; lines 172–216 in `pydantic_agent.py`).

Update plan:

- Replace tool-name references with the new ones.
- Replace any "use `CodeChangesManager` to stage edits" framing with
  "edit the file directly; the worktree is yours." Make explicit that
  edits are durable from the moment the tool returns and that
  `sandbox_git_commit` formalizes them.
- Add a short "Sandbox tools" section enumerating the catalog, mirroring
  how `bash_command_tool`'s description is structured.
- Verify the `local_mode` branch of the prompt still hangs together
  given §9.3 — the local-mode tools stay, just without the sandbox
  ones.

The prompts are long. The implementer should diff carefully and run the
agent against a small smoke test (the existing E2E in
`agents/integration/`) before declaring victory.

---

## 12. Phasing

Rough order; each phase is independently shippable.

1. **Image + ripgrep.** Ship `potpie/agent-sandbox` Docker image,
   register as the daytona snapshot default. Verify `rg`, `git`, `jq`
   present in all backends.
2. **Client surface.** `SandboxClient` over existing service. Helpers
   (`read_file`, `write_file`, `list_dir`, `search`, `commit`, `push`).
   Unit tests. No callers yet.
3. **One sandbox per `(user, project)`** for the daytona backend.
   Worktree-per-branch inside it. New e2e test that two workspace
   requests with different branches share the same Daytona sandbox.
4. **Sandbox-backed agent tools** registered in `ToolService`. Old
   tools still present.
5. **Migrate `code_gen_agent`** to the new tools. Update its prompt.
   Smoke test.
6. **Migrate parsing.** `ParseHelper` uses `SandboxClient` via local-fs
   backend. Re-run parsing tests.
7. **Migrate remaining agents.** One per PR; same pattern as `code_gen`.
8. **Delete `CodeChangesManager`.** Including its Redis schema,
   lifecycle plumbing, and the staging tool family.
9. **Move `.repos` glue into the local adapter.** Delete
   `app/modules/repo_manager/`. Migrate `.meta` JSON state on first
   read.
10. **Durable store + Redis locks.** When we go multi-worker.
11. **Background eviction.** Separate from the request path.

Phases 1–3 are foundational and can land concurrently. 4–7 are the
visible migration. 8–9 are the cleanup. 10–11 are scaling work, not
correctness work.

---

## 13. Open questions

These are real choices the implementer should make explicitly, not pretend
the plan answered:

- **One image or two.** Slim vs full. Cold start vs feature coverage.
  Daytona snapshot pull time matters here — measure it.
- **Per-user vs shared `RepoCache`.** Sharing saves disk and clone time;
  separating is simpler for auth and quota. Default to per-user; design
  the key so we can flip later.
- **Where the `SandboxRunContext` contextvar lives.** Closest analogue
  is `_code_changes_manager_ctx` in
  `code_changes_manager/context.py`. Same pattern, different module.
- **Whether to keep `WorkspaceMode`.** ANALYSIS / EDIT / TASK currently
  drive branch naming. Once parsing goes through us with mode=ANALYSIS
  and agents with mode=EDIT, we have one more user (TASK?) — or we
  collapse to two (read vs write). Simpler is better; collapse if the
  test suite still passes.
- **Streaming exec.** `exec_stream` exists in the port but isn't used.
  Promote it once an agent flow needs progress (long test runs, builds).
- **PR creation.** `code_provider_create_pr` is a tool today and lives
  outside the sandbox. Either fold it into `sandbox_open_pr` (clean,
  but ties auth to the sandbox lib), or keep it separate (clean
  layering, slightly worse ergonomics). Lean toward separate.
- **Multi-tenancy at the Daytona level.** One Daytona org per potpie
  deployment? Per user? The dev stack has one `dev@daytona.io` user;
  production needs a story. Out of scope here, but the sandbox client's
  config shape should not preclude per-user Daytona credentials.

---

## 14. What this replaces, in one table

| Concept                                  | Today                                                                | After                                                       |
| ---------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------- |
| Bare repo + worktree on disk             | `app/modules/repo_manager/`                                          | `app/src/sandbox/.../adapters/outbound/local/`              |
| In-flight file edits                     | `CodeChangesManager` (Redis, 24h TTL)                                | The worktree + git                                          |
| Edit-staging tools (~20)                 | `code_changes_manager/tools.py`                                      | A dozen `sandbox_*` tools, mostly just file/git ops         |
| Repo materialization for parsing         | `ParseHelper.clone_or_copy_repository` → `RepoManager`               | `SandboxClient.get_workspace(mode=ANALYSIS)`                |
| Repo materialization for agents          | Tool internals call `get_or_create_edits_worktree_path` per call     | One `SandboxClient.get_workspace` per run, cached on ctx    |
| Branch concurrency                       | One worktree per `(user, repo, conversation)` on disk                | Many worktrees per Daytona sandbox; one cache per repo      |
| Clone auth                               | `sync_helper.py` (GitHub App → OAuth → env)                          | Same code, lives in the local adapter                       |
| `.repos` directory                       | The blessed location                                                 | One adapter's storage; equivalent in Daytona is a sandbox   |
| Eviction                                 | Inline during `prepare_for_parsing`                                  | Background task per adapter                                 |
| Locks                                    | None at app layer; OS file locks for git                             | `LockManager` per workspace; pluggable Redis impl           |
| Durable store                            | DB tables for `Project`; JSON in `.meta/`                            | `SandboxStore` (Postgres adapter when multi-worker)         |
| Tool wiring                              | Hardcoded allow-lists + emerging registry                            | Registry-only; sandbox tools as a group                     |

---

If something in here looks underspecified, that's by design — the plan
should make the call points obvious without freezing the implementation.
The right next step is phase 1: build the image, snapshot it, and prove
`rg` is available in a Daytona sandbox end-to-end. Everything else
follows.

---

### Vscode Extension Debug Handoff (`docs/vscode-extension-debug-handoff.md`)

---
title: VS Code extension debug handoff
description: Debug and DAP issues in the VS Code extension tunnel and adapter detection.
---

## Overview

Date: 2026-05-25

## Context

The debug agent can reach the local workspace over the Socket.IO tunnel, but C/C++
debugging currently fails or degrades in two ways:

1. Terminal commands such as `gcc -g ...` and `mkdir ...` can complete locally while
   the backend waits until the RPC timeout because no `tool_response` is observed.
2. `get_workspace_debug_context` reports `debug_adapters: ["python", "node"]` even
   on a macOS machine where LLDB and `lldb-dap` are installed. This blocks C/C++
   debug session setup from the agent's point of view.

This is not expected to be a local network or WebSocket reliability problem. The
backend timeout means: `tool_call` was emitted, but no matching `tool_response`
arrived on Redis pub/sub before the timeout.

Latest isolated harness verification after the extension-side terminal fix:

- `/api/terminal/execute` for `cc -g -o add_numbers add_numbers.c` returned in
  roughly 400ms with `success=true`, `exit_code=0`, empty stdout/stderr, and
  `timed_out=false`.
- `/api/workspace/debug-context` returned C/C++-relevant adapters:
  `["python", "node", "lldb", "lldb-dap"]`.
- `/api/debug/start-session` with `lldb-dap` succeeded and returned a session id.
- `/api/debug/set-breakpoints` at `add_numbers.c:4` returned a verified
  breakpoint.
- `/api/debug/snapshot` after continue still failed with
  `Cannot take snapshot: session is running, expected paused`.

That harness run was a smoke test, not a full DAP tool matrix. It did not cover
`step-over`, `step-into`, `step-out`, `evaluate`, `list-sessions`, or a clean
`stop-session` path.

## Backend Contract

Socket event from backend to extension:

```json
{
  "correlation_id": "<uuid>",
  "endpoint": "/api/terminal/execute",
  "payload": {},
  "timeout": 35.0
}
```

The extension must always respond with:

```json
{
  "correlation_id": "<same uuid>",
  "success": true,
  "result": {}
}
```

or:

```json
{
  "correlation_id": "<same uuid>",
  "success": false,
  "error": "..."
}
```

Important backend routes used by the debug agent:

| Endpoint | Purpose |
| --- | --- |
| `/api/workspace/debug-context` | Return launch configs, available debug adapters, inferred commands |
| `/api/terminal/execute` | Run shell commands in sync or async mode |
| `/api/terminal/sessions/{session_id}/output` | Poll async terminal output |
| `/api/terminal/sessions/{session_id}/signal` | Stop async terminal process |
| `/api/debug/start-session` | Start DAP debug session |
| `/api/debug/set-breakpoints` | Set breakpoints |
| `/api/debug/snapshot` | Capture stack/locals/expressions |
| `/api/debug/list-sessions` | List debug sessions |
| `/api/debug/stop-session` | Stop debug session |

## Required Extension Changes

### 1. Always Emit `tool_response`

Every extension route handler must emit a response for every received
`correlation_id`, including:

- success with empty stdout
- non-zero exit
- thrown exception
- timeout
- unknown route
- debug adapter launch failure

This should be implemented with a top-level `try/catch/finally` around dispatch so
no handler can accidentally leave the backend waiting.

### 2. Fix Terminal Completion For Silent Commands

The terminal executor must not infer completion from visible terminal output.
Commands like these are valid and often produce no stdout:

```bash
gcc -g -o add_numbers add_numbers.c
mkdir -p .vscode
true
```

The user-facing contract is that terminal commands should be visible in a VS Code
integrated terminal by default. A hidden `child_process` result is acceptable only
for an explicitly hidden/internal mode; it is not acceptable for normal debug-agent
terminal commands because users must be able to see what the agent ran.

Recommended behavior:

- Open or reuse a named integrated terminal such as `Potpie`.
- Show the command in that terminal before execution.
- Capture stdout, stderr, exit code, duration, and timeout for the RPC response.
- Append an internal sentinel to detect completion reliably when the command is
  silent.
- Keep the terminal visible unless the payload explicitly requests hidden
  execution.

Implementation options:

- If using an integrated terminal/PTY, append an explicit sentinel that includes
  exit code, then parse that sentinel, e.g. `printf "\n__POTPIE_EXIT:$?__\n"`.
- If using `child_process.spawn` / `execFile`, wire it only to an explicit hidden
  mode or mirror the command/output into the visible terminal.

Expected sync result shape:

```json
{
  "success": true,
  "command": "gcc -g -o add_numbers add_numbers.c",
  "output": "",
  "error": "",
  "exit_code": 0,
  "duration_ms": 1234
}
```

### 3. Return Accurate `debug_adapters`

`debug_adapters` should represent debug adapter types that the extension can
actually launch in the current VS Code/Cursor session.

For C/C++ on macOS, include at least one of:

- `lldb` when CodeLLDB (`vadimcn.vscode-lldb`) is available
- `cppdbg` when Microsoft C/C++ (`ms-vscode.cpptools`) is available
- `lldb-dap` or another explicit adapter id only if the extension start-session
  handler can launch it directly

Do not infer this only from binaries on disk. `/usr/bin/lldb` and
`/Library/Developer/CommandLineTools/usr/bin/lldb-dap` prove the machine has LLDB,
but they do not prove VS Code has a registered adapter type unless the extension can
use them.

If the extension can launch `/Library/Developer/CommandLineTools/usr/bin/lldb-dap`
directly without a marketplace extension, return a capability that makes that
explicit, for example:

```json
{
  "debug_adapters": ["python", "node", "lldb-dap"],
  "native_debuggers": {
    "lldb": "/usr/bin/lldb",
    "lldb_dap": "/Library/Developer/CommandLineTools/usr/bin/lldb-dap"
  }
}
```

### 4. Support C/C++ Launch Configs

The extension should accept launch configs from `.vscode/launch.json` and start
sessions for C/C++ configs such as:

```json
{
  "type": "cppdbg",
  "request": "launch",
  "name": "Debug add_numbers",
  "program": "/Users/deepesh/work/valkey/add_numbers",
  "cwd": "/Users/deepesh/work/valkey",
  "MIMode": "lldb"
}
```

and/or CodeLLDB style:

```json
{
  "type": "lldb",
  "request": "launch",
  "name": "Debug add_numbers",
  "program": "/Users/deepesh/work/valkey/add_numbers",
  "cwd": "/Users/deepesh/work/valkey"
}
```

If an adapter is missing, return a structured failure response instead of timing
out:

```json
{
  "success": false,
  "error": "debug_adapter_unavailable",
  "message": "No registered debug adapter for type 'cppdbg'. Install ms-vscode.cpptools or use lldb."
}
```

### 5. Add Correlation-ID Tracing

Log these lifecycle points with `correlation_id`, `endpoint`, and `workspace_id`:

- received `tool_call`
- selected handler
- handler started
- handler completed
- emitted `tool_response`
- handler exception
- handler timeout

This is the fastest way to distinguish:

- backend emitted to stale socket id
- extension received but handler did not respond
- extension responded but socket server did not publish to Redis
- backend worker did not receive Redis pub/sub message

## Acceptance Checks

With the extension connected to the workspace:

1. `execute_terminal_command("true")` returns exit code 0 without timeout.
2. `execute_terminal_command("mkdir -p .vscode")` returns exit code 0 without timeout.
3. `execute_terminal_command("gcc -g -o add_numbers add_numbers.c")` returns exit
   code 0 without timeout, even with empty stdout.
4. `get_workspace_debug_context` includes the C/C++ adapter that the extension can
   actually launch (`lldb`, `cppdbg`, or `lldb-dap`).
5. `start_debug_session` for the compiled `add_numbers` binary returns a session id
   or a structured adapter-unavailable error; it must not time out silently.
6. `set_breakpoints` at `add_numbers.c:4` and `take_debug_snapshot` return locals
   when the program pauses.
7. User-facing terminal commands visibly open or reuse an integrated terminal and
   show the command being run; the RPC response must not be produced only by a
   hidden process in normal mode.

## Backend Follow-Up Needed

The backend currently models `start_debug_session.language` as:

```python
Literal["python", "node", "go", "unknown"]
```

To make C/C++ first-class, backend should add `c`, `cpp`, or a direct launch-config
type field and pass through adapter-specific launch configs without forcing them
through language-only routing.

---

### Context Graph Architecture (`docs/context-graph/architecture.md`)

---
title: Context graph architecture
description: "Hexagonal layers, composition roots, daemon model, and the GraphBackend port."
---

## Overview

> Status: reflects the canonical Context Runtime boundary, last reviewed 2026-08-24.

This is the implementation map for the Context Graph: the layered code, the
composition roots that wire it, the daemon that hosts it, the swappable
`GraphBackend`, and the shared storage engine room underneath. It orients you to
the moving parts and points at the docs that own each flow in full — the read
path lives in [querying.md](./querying.md), the write path in
[writing.md](./writing.md), the vocabulary in [ontology.md](./ontology.md), and
ingestion/nudge in [ingestion-nudge.md](./ingestion-nudge.md).

The single most important framing fact: the `potpie graph …` workbench is
**shipped today** as the V1.5 surface. The string `v2` survives only as the
workbench *envelope* version (`GRAPH_WORKBENCH_CONTRACT_VERSION="v2"`); the data
plane is `GRAPH_CONTRACT_VERSION="v1.5"` / `ONTOLOGY_VERSION="2026-06-graph"`.
Both the legacy `resolve`/`search`/`record` wrappers and the full workbench ship
now. The **canonical write door is `graph propose` → `graph commit --verify`**;
`graph mutate` is a legacy wrapper over it (see [Writing the graph, high
level](#the-write-door-high-level)).

## Hexagonal layers

The engine is ports-and-adapters. Pure model in the middle, I/O at the edges,
composition roots at the top.

```mermaid
flowchart TB
  cg_inbound["inbound adapters<br/>typed clients · http ingestion · daemon protocol · webhooks"]
  cg_app["application<br/>services · readers · use_cases"]
  cg_domain["domain<br/>ontology · contract · ports · DTOs · ranking · coherence · identity"]
  cg_outbound["outbound adapters<br/>graph backends + engine room · connectors · ledger clients · postgres event store · local embedder"]
  cg_boot["composition roots<br/>potpie/runtime/composition.py · ingestion_server.py"]
  cg_host["runtime boundary<br/>ContextEngine · Resource Manager · EngineClient · daemon"]

  cg_boot --> cg_host
  cg_boot --> cg_inbound
  cg_inbound --> cg_app
  cg_app --> cg_domain
  cg_app --> cg_outbound
  cg_outbound --> cg_domain
```

| Layer | Path | Responsibility |
|---|---|---|
| **domain/** | `domain/` | Pure model and contracts: the three ontology catalogs, contract constants, ports (Protocols), DTOs, ranking, coherence invariants, identity. No I/O. Import-time coherence guards fail startup fast if vocabularies drift. |
| **application/** | `application/` | Services and use cases that orchestrate domain over ports: `services/` (graph_service, graph_workbench, agent_context, read_orchestrator, envelope_builder, nudge_service, skill_manager, semantic_mutation_validator/lowering, reconciliation_validation, ingestion_submission_service), `readers/` (the 10 readers), `use_cases/` (the ingestion pipeline). |
| **adapters/** | `adapters/inbound`, `adapters/outbound` | Concrete I/O. Inbound: HTTP ingestion server and webhooks. Outbound: graph backends + the shared engine room, connectors, ledger clients, the Postgres event store, `intelligence/local_embedder`, the session injection ledger. Root CLI, daemon, and capability adapters live under `potpie/`. |
| **bootstrap/** | `bootstrap/` | Context Engine standalone HTTP composition. The local product composition lives under `potpie/runtime/`. |
| **runtime + daemon** | `potpie/runtime/`; `potpie/daemon/` | Context-bound clients, authorization/resource management, typed daemon protocol, and root capability lifecycle. |

The local spine is `CLI → EngineClient → ContextResourceManager → ContextEngine`
for engine-owned operations. Root capability commands use finite root-owned
services. Agents reach both through the CLI.

## Two composition roots / two HTTP roots

There are **two separate composition roots**, and conflating them is the most
common architecture error.

```mermaid
flowchart TB
  subgraph cg_spine["Local agent spine — potpie/runtime/composition.py"]
    direction TB
    cg_cli["potpie CLI · LocalEngineClient / DaemonEngineClient"]
    cg_shell["ContextResourceManager<br/>authorized context lease"]
    cg_services["ContextEngine + root capability services"]
    cg_lite[("default backend: falkordb_lite<br/>default host mode: daemon")]
    cg_cli --> cg_shell --> cg_services --> cg_lite
  end

  subgraph cg_ingest["Ingestion HTTP server — bootstrap/ingestion_server.py"]
    direction TB
    cg_fastapi["FastAPI app (adapters/inbound/http/)"]
    cg_pipeline["async reconciliation pipeline · connectors · optional reconciliation agent"]
    cg_neo[("default backend: neo4j")]
    cg_fastapi --> cg_pipeline --> cg_neo
  end
```

1. **The local agent spine** — `potpie/runtime/composition.py
   build_local_runtime()` composes separate root capability and engine service
   groups. Engine-owned CLI operations use `LocalEngineClient` in-process or
   `DaemonEngineClient` over the versioned authenticated daemon protocol. The
   daemon listens privately over UDS with loopback TCP fallback. Root capability
   commands use finite root-owned services. **Default backend:
   `falkordb_lite`. Default host mode: `daemon`.**
2. **The ingestion HTTP server** — `bootstrap/ingestion_server.py
   IngestionServerContainer` composes the FastAPI app under
   `adapters/inbound/http/` (the async reconciliation pipeline, connectors, the
   optional reconciliation agent). That public FastAPI app is the **second HTTP
   root**. **Default backend here: `neo4j`** (via `settings.graph_db_backend()`).
   It is a distinct Context Engine delivery root.

Both roots run the same domain/application code over a swappable `GraphBackend`;
they differ only in wiring and default storage.

A third deployment serves the same typed operations remotely: a **managed
host** (next section). The `cloud` command group is still roadmap and raises
`CapabilityNotImplemented`.

## Managed hosts: one protocol, two deployments

A managed host is a hosted service that embeds the same engine and serves the
same typed envelope as the local daemon, at `POST <base>/v1/operations` over
HTTPS with `Authorization: Bearer <per-actor key>`. There is no second wire
format and no other remote route.

```mermaid
flowchart LR
  mh_cli["potpie CLI"] -->|"origin = local"| mh_daemon["local daemon<br/>UDS / loopback"]
  mh_cli -->|"origin = managed"| mh_remote["RemoteEngineClient +<br/>ManagedPotDirectory"]
  mh_ui["explorer page"] -->|"loopback only"| mh_daemon
  mh_daemon -->|"?host=managed<br/>key held here"| mh_remote
  mh_remote -->|"HTTPS · Bearer"| mh_host["managed host<br/>OperationEndpoint (serving kit)"]
```

**Client side** (root `potpie`):

- `potpie.config.host_registry` — the origins, `<home>/cli_hosts.json`, the URL
  and TLS rules, and which origin an invocation targets. It lives under
  `config` because the daemon's explorer reads it too.
- `potpie.runtime.remote` — `RemoteEngineClient` (the engine catalog for one
  selector), `RemoteHostClient` (`host.*` and extension operations),
  `ManagedPotDirectory`, and `connect()` → `HostConnection` with one pooled
  keep-alive HTTPS client per host and event loop.
- `potpie.pots.directory.PotDirectory` — the pot control plane of one host:
  `LocalPotDirectory` over the in-process pot service, `ManagedPotDirectory`
  over `host.*`. CLI pot, source, grant, `whoami` and `status` commands use it.
- `potpie.daemon.http.ui.source.ExplorerSource` — the explorer reads either the
  daemon's own services (local) or the remote clients (managed); the whole
  graph is the `graph_slice` operation in both.

**No lock-step.** The local daemon keeps its catalog fingerprint and per-boot
tickets. A managed host issues no tickets and keeps no per-boot state: each
request is checked on its own. Requests leave default-valued fields off the
wire, a host refuses an operation or field it does not know by name, the client
reports that as `host_outdated`, and results decode leniently. A newer client
keeps working against an older host for every call that does not need the new
thing.

**Identity and selection.** The host authenticates the bearer before it
decodes or selects anything, then resolves the pot selector for that actor
(explicit, active, or repository, using only the pots the actor holds a grant
on) and authorizes the operation for the actor's role. `host.*` operations
(pots, sources, repo defaults, grants, `whoami`) are root-Potpie product
operations the local daemon does not serve; `ext.<vendor>.*` names are
registered by the embedding host.

**Server side.** `potpie.runtime.hosting` is the serving kit an embedding host
builds on: `OperationEndpoint`, the actor-aware `ContextResourceManager`,
`CatalogSelectorResolver`, the host-operation handler protocol, extensions and
scoped commit access. `potpie.runtime.hosting_testing` holds the client half a
host's contract tests need. Both surfaces are provisional: names may still move
while the managed-host contract settles.

## The daemon model

`potpie/daemon/lifecycle.py Daemon` is the local background-process lifecycle
service; `potpie/runtime/controller.py` owns direct subprocess observation and
the typed protocol provides IPC. The daemon is explicitly **not** the business
layer. Two modes:

- **in_process** — `LocalEngineClient` invokes the same typed operation handlers
  and Resource Manager in the CLI process.
- **daemon** — a separate `python -m potpie.daemon` process serves the typed,
  authenticated protocol over UDS or loopback TCP. The CLI uses
  `DaemonEngineClient` after a successful handshake.

The handshake compares the recursive operation and wire-schema catalog and
returns a unique, opaque, boot-scoped compatibility ticket. The daemon derives
the ticket from its per-boot secret and requires it on every later non-handshake
request. The ticket proves that one client negotiated this protocol catalog; it
is not caller identity or authorization. Bearer authentication is checked
separately and remains the current transitional daemon-auth mechanism, not the
final identity model for local IPC, Potpie login, hosted clients, integrations,
or browser access.

Context-free typed metadata such as `graph describe` still crosses the
authenticated daemon boundary but bypasses context selection and engine
leasing. Destructive graph reset takes the opposite path: the CLI binds
confirmation to one resolved context and the selected runtime's Context Engine
executes the reset against its own backend. Pot metadata services never reset a
graph or compose a second backend.

Mode is chosen by `CONTEXT_ENGINE_HOST_MODE` (`daemon` | `in_process`);
`default_host_mode()` returns **`daemon`**, so the shipped CLI default is a
background daemon. `get_engine_client()` (`commands/_common.py`) selects the
daemon client by default and the local client when
`CONTEXT_ENGINE_HOST_MODE=in_process`.

**Liveness ≠ readiness.** The daemon can be live while a backend or semantic
index is not yet ready; the two are reported separately. `potpie doctor`
composes `backend.capabilities()` + `backend.mutation.readiness()` +
`daemon.status()` + `ledger.status()` so a half-ready system is debuggable.
`potpie ui` (the read-only graph explorer) is served by this daemon — it ensures
the daemon, discovers `base_url`, and opens `<base>/ui?[pot=<id>&]k=<code>`.
`/ui/api` takes the daemon's bearer token or a session cookie; `k` is a
single-use code the CLI mints with that token, which the page trades for an
HttpOnly session cookie, so the token itself never reaches the browser.

## CLI surfaces onto one data plane

Reads and writes both reach `DefaultGraphService`
(`application/services/graph_service.py`) over the backend, but the CLI exposes
two shapes:

- **Compatibility commands** — `potpie resolve` / `search` / `record` /
  `status`, bound by `AgentContextService`.
- **Graph Surface Lite (V1.5)** — the richer `potpie graph …` workbench
  (`catalog`/`read`/`search-entities`/`neighborhood`/`propose`/`commit`/`bulk`/
  `history`/`inbox`/`quality` …).

Both are shipped. The read shapes and the 9-reader trunk are owned by
[querying.md](./querying.md); the write stack is owned by
[writing.md](./writing.md). No read returns a server-synthesized answer —
reads return ranked evidence (`AgentEnvelope`) and the agent reasons over it.

## The `GraphBackend` port

`domain/ports/graph/backend.py` defines `GraphBackend` as a `@runtime_checkable`
Protocol that **bundles six capability ports** in two tiers, plus three bundle
members.

```mermaid
flowchart TB
  cg_be["GraphBackend"]
  subgraph cg_canon["canonical source of truth"]
    cg_mut["mutation (GraphMutationPort)"]
    cg_cq["claim_query (ClaimQueryPort)"]
  end
  subgraph cg_proj["rebuildable projections"]
    cg_sem["semantic (SemanticSearchPort)"]
    cg_insp["inspection (GraphInspectionPort)"]
    cg_an["analytics (GraphAnalyticsPort)"]
    cg_snap["snapshot (GraphSnapshotPort)"]
  end
  cg_be --> cg_mut
  cg_be --> cg_cq
  cg_be --> cg_sem
  cg_be --> cg_insp
  cg_be --> cg_an
  cg_be --> cg_snap
  cg_be -. "profile · capabilities() · provision()" .-> cg_meta["bundle members"]
```

- **Canonical:** `mutation` (`apply`/`apply_async`, `invalidate`, `reset_pot`,
  `readiness`) and `claim_query` (`find_claims`, `entity_labels`,
  `entity_properties`). Note `ClaimQueryPort` lives at
  `domain/ports/claim_query.py`, **not** under `graph/`.
- **Projections (rebuildable):** `semantic` (vector NN over claim embeddings,
  stamps `semantic_similarity`), `inspection` (`neighborhood`/`path`/`labels`/
  `slice` → backs `graph neighborhood`/`inspect` + the explorer UI), `analytics`
  (`counts`/`freshness`/`quality`/`repair`), `snapshot` (`export`/`import_`).
- **Bundle members:** `profile` (string), `capabilities() -> BackendCapabilities`
  (a frozen dataclass declaring which of the six are *really* implemented vs
  fail-closed — read by `backend status`/`doctor`), and `provision() ->
  BackendProvisionResult` (`domain/ports/provisioning.py`; the setup seam where a
  backend stands up its own store idempotently).

Two workbench stores also live under `domain/ports/graph/` but are **not** part
of the six-cap bundle: `inbox_store.py` and `plan_store.py`.

The registry (`adapters/outbound/graph/backends/__init__.py`) is
`build_backend(profile, *, settings, embedder)`. `KNOWN_PROFILES = (in_memory,
embedded, neo4j, falkordb, falkordb_lite, postgres, chroma, hosted)`. It
normalizes names (`falkordblite`→`falkordb_lite`, dashes→underscores) and, when
`embedder is None` for a real profile, default-builds the bundled local embedder
(disable via `CONTEXT_ENGINE_EMBEDDER=none`).

### Backend coverage — real capabilities per profile

This table is the corrected source of truth (the older doc overstated coverage).
A "real cap" is one whose port actually executes; everything else is fail-closed
and raises `CapabilityNotImplemented`.

| Profile | Backend class | Real caps | Notes |
|---|---|---:|---|
| `in_memory` | `InMemoryGraphBackend` | **6/6** | Conformance/reference; genuinely real (validates, MERGEs by identity, bitemporal invalidation, embeds on write); `dump_store`/`load_store`. |
| `embedded` | `EmbeddedGraphBackend` | **6/6** (delegated) | OSS JSON-persisted fallback wrapping `in_memory`; persists to `<home>/graph.json` after each mutation (atomic tmp-replace). |
| `falkordb_lite` | `FalkorDBLiteGraphBackend` | **6/6** | **The OSS/CLI default.** Embedded FalkorDBLite via `redislite` over a local file — no server, no Docker. |
| `falkordb` | `FalkorDBGraphBackend` | **6/6** | Full FalkorDB server over a redis URL; needs the optional `falkordb` client. |
| `neo4j` | `Neo4jGraphBackend` | **5/6** (no inspection) | "Shape-first production target"; native relationship vector index. |
| `postgres` / `chroma` / `hosted` | `StubGraphBackend` | **0/6** | Fail-closed seam: every port and `provision` raise `CapabilityNotImplemented("graph.<profile>.<cap>.<method>")`. Documented but unbuilt; `backend list` still shows them. |

**Cross-profile gaps to internalize:**

- Claim-key `mutation.invalidate` runs through the native atomic writer on
  Neo4j and FalkorDB; it raises `CapabilityNotImplemented` only for an injected
  legacy writer that has no atomic mutation surface.
- `snapshot` (export/import) is implemented on `in_memory`, `embedded`,
  `falkordb_lite`, `falkordb`, and `neo4j`; FalkorDB and Neo4j apply an import
  atomically against the pot's revision (see `graph export`/`graph import` in
  [cli-flow.md](./cli-flow.md)).
- `inspection` is real on `in_memory`/`embedded`/`falkordb` but **not** Neo4j.
- Net effect: **FalkorDB is more complete than Neo4j** (6 vs 5 ports), and the
  OSS default `falkordb_lite` is a first-class backend, not a stub.

> **Roadmap (not yet wired):** `inspection` on neo4j; the
> `postgres`/`chroma`/`hosted` backends (all `StubGraphBackend`).

## The shared engine room

The real storage logic is shared across backends, not duplicated per profile
(`adapters/outbound/graph/`). This is the implementation underneath the canonical
ports.

| Module | Role |
|---|---|
| `cypher.py` | The **Position-B canonical write engine shared by BOTH the Neo4j and FalkorDB writers.** Claims are stored as `(:Entity {group_id, entity_key})-[:RELATES_TO {group_id, name, subject_key, object_key, source_ref, valid_at, invalid_at, created_at, …}]->(:Entity)`. The MERGE key **includes `source_ref`** so corroborating writes from different sources don't collide; bitemporal stamps (`valid_at`/`invalid_at` = event time, `created_at` = system time); `_supersede_singleton_predecessors` stamps `invalid_at` on prior disagreeing live singleton claims. A Phase-0 spike proved this Cypher runs unchanged on FalkorDB. |
| `canonical_claim_query.py` | Shared `FIND_CLAIMS_CYPHER`, `ENTITY_LABELS_CYPHER`, `row_from_record`, the lexical `embedding_score` token-overlap scorer, `stamp_similarity`/`stamp_scored_rows`, `CONTRACT_EDGE_KEYS`. Both readers reuse these; the FalkorDB reader only swaps row normalization. |
| `claim_query_analytics.py` / `claim_query_semantic.py` | Give any claim-backed profile a real analytics projection and a `fact_query`-routed semantic search computed on read. |
| `apply_plan.py` | `apply_mutation_batch` (alias `apply_reconciliation_plan`) — the **single async write door** (high-level below; full flow in [writing.md](./writing.md)). |
| `writer_port.py` | `GraphWriterPort` — the narrow async mutation surface (`ensure_indexes`, 4 verbs, `reset_pot`), implemented by `Neo4jGraphWriter` and `FalkorDBGraphWriter`. Storage-internal; services depend on `GraphBackend`/`GraphMutationPort`, not this. |
| `in_memory_reader.py` | `InMemoryClaimQueryStore` — full filter semantics; `match_mode` = `vector` (embedder cosine over `fact_embedding`) or `lexical` (token overlap). |
| `context_graph_service.py` | Legacy `ContextGraphPort` DTO shim; `apply_plan`/`apply_plan_async` are **disabled** (raise `CapabilityNotImplemented`) — writes must go through `GraphService.mutate`. |
| `plan_stores/local_json.py`, `inbox_stores/local_json.py` | JSON workbench stores (plans, inbox) under the Potpie home. |

`FalkorDBGraphProvider` memoizes **one** graph handle so the Falkor reader and
writer share a single embedded instance.

## The write door (high level)

The canonical write door is two-phase: **`graph propose` → `graph commit
--verify`** (Spine A, `application/services/graph_workbench.py`). `propose`
validates, lowers accepted ops, and persists a plan record with no graph write;
`commit` re-checks the conflict guard, enforces approval for medium/high risk,
calls the single backend write door, and `--verify` reads the committed claims
back.

```mermaid
flowchart LR
  cg_propose["potpie graph propose<br/>(validate · lower · persist plan)"]
  cg_commit["potpie graph commit --verify<br/>(re-check · apply · read-back)"]
  cg_apply["apply_mutation_batch<br/>(single backend write door)"]
  cg_writer["GraphWriterPort<br/>upsert_entities → upsert_edges → delete_edges → invalidate"]
  cg_propose --> cg_commit --> cg_apply --> cg_writer
```

Two corrections to the previous version of this doc:

- **`graph mutate` is a legacy wrapper, not the canonical door.** It internally
  calls `propose` then `commit` and emits a legacy warning
  (`commands/graph.py`). The direct-apply `DefaultGraphService.mutate` (Spine B)
  is now reached **only** through `record` / `context_record`, never via `graph
  mutate`.
- There **is** a server-created `plan_id` and a real `propose|commit` contract —
  the prior claim that none existed was wrong.

Optimistic concurrency on this path is per pot: a plan records the pot's
monotonic revision (`{"_global": <revision>}`) and commit compares it while
applying — there are no per-subgraph versions (writing.md §5.2 has the
per-backend guarantees). `GraphMutationDiff.to_dict()` emits `entity_upserts`, `edge_upserts`,
`edge_deletes`, `invalidations`, `claims_asserted`, `claims_retracted`,
`claim_keys`. The full tiered stack — semantic DSL (flat ops), validation/risk,
lowering, the 4-verb idempotent apply, inbox, and quality — is owned by
[writing.md](./writing.md).

## Persistence & per-pot scoping

Every fact is scoped by **`pot_id`, which IS the Cypher `group_id`** on every
`:Entity` node and `:RELATES_TO` edge. `reset_pot` is `MATCH (n {group_id:$gid})
DETACH DELETE n`. There is no cross-pot federation (an explicit anti-goal).

| Profile | Where it persists | Pot isolation |
|---|---|---|
| `in_memory` | Process-local, ephemeral | by `group_id` |
| `embedded` | One multi-pot `<home>/graph.json`, atomic rewrite per mutation | by `group_id` |
| `falkordb_lite` | `redislite` file at `falkordb_lite_path()` (default `.potpie/context_graph/falkordb.db`), single keyspace `context_graph` | by `group_id` |
| `neo4j` | External server, one DB | by `group_id` |

Relevant settings (`domain/ports/settings.py`): `graph_db_backend()='neo4j'`,
`falkordb_mode()='lite'`, `falkordb_graph_name()='context_graph'`,
`falkordb_lite_path()='.potpie/context_graph/falkordb.db'`, plus the Neo4j URI/
credentials.

## Resource store

The graph holds claims and pointers, never payloads. A document's content lives
in the **resource store** instead, split in two:

- **Bytes** become chunk files on disk, pot-scoped, behind `ResourceStorePort`
  (`core/ports/resource_store.py`) so another backend can replace local disk.
- **Structure** goes into the graph: a `Document` (`document:<slug>`) owns one
  `DocumentSection` (`docsection:<doc>:<section>`) per real division of the
  source through `SECTION_OF`, and each section's summary is a claim normal
  retrieval finds. Chunks are files, not nodes, so a 500-page PDF becomes tens
  of nodes, not hundreds.

Potpie does not parse documents. The agent writes an extraction script that
emits a chunk directory (`meta.json` plus `<section>/<seq>.txt`) and
`potpie resource import` absorbs it, so chunk text never passes through the
agent's output tokens. The `potpie-resource-*` skills teach that flow
([skills.md](./skills.md)); the commands are in [cli-flow.md](./cli-flow.md).

```text
evidence id   potpie://res/<doc>/<section>/<seq>@rev<N>   immutable; seq zero-padded to 4 digits
chunk file    <home>/resources/<pot_dir>/<doc>/<section>/<seq>.txt
prior revs    <home>/resources/<pot_dir>/<doc>/.versions/<N>/
pot_dir       <sanitized pot id>-<sha256(pot id)[:16]>     injective; never escapes the root
chunk size    target 4,000 chars, hard cap 8,000           rejected at import, never clamped at read
limits        import 64 MiB of UTF-8 per call; get at most 128 ids per call
```

- **Import ships data, never a path.** The CLI reads the directory and sends its
  files, so the in-process runtime and the daemon behave the same. The order is
  bytes, then index, then graph: there is no cross-store transaction, and bytes
  first fails to harmless orphan files instead of claims citing missing chunks.
  An index failure is a warning that `resource index rebuild` recovers.
- **Revisions are retained.** A re-import that changes sections, bytes or
  citation-visible metadata publishes a new revision and keeps the prior bytes,
  so a versioned citation keeps opening the text it cited. A missing revision is
  `resource_not_found`; an unversioned id on a multi-revision document is
  `resource_revision_ambiguous`; neither falls back to the current text. Claims
  citing a changed or removed section are flagged `evidence_review_required`,
  not rewritten; fixing them is an ordinary propose/commit correction. Retention
  is unbounded until `resource rm`.
- **Retrieval index.** `sqlite_hybrid` (default: BM25 plus vectors, fused by
  reciprocal rank, reporting itself lexical with a reason when vectors are
  unavailable), `sqlite_fts`, or `none`, chosen by
  `CONTEXT_ENGINE_RESOURCE_INDEX`, then the `resource_index` config key. Import
  writes lexical rows inline; vectors drain in the background in the daemon or
  an in-process engine host. Similarity bands are calibrated for
  `all-MiniLM-L6-v2` only: other embedders get rank-and-coverage relevance and
  refuse an explicit passage `--query-threshold`.
- **Teardown.** `pot reset` and `pot archive` purge the pot's documents and index
  rows after the graph reset succeeds and report `resources_purged`.
  `source remove` never touches documents.

## Backend selection precedence

- **Host / CLI:** `CONTEXT_ENGINE_BACKEND` (preferred) > legacy
  `GRAPH_DB_BACKEND` > `default_backend_profile()` = **`falkordb_lite`**.
- **Ingestion server:** `settings.graph_db_backend()` default **`neo4j`**, but it
  also routes through `build_backend`, so FalkorDB works there too.

There is **no `NotImplementedError` gate** on FalkorDB anywhere — the old
"selecting falkordb raises NotImplementedError, use neo4j" guidance is obsolete.
`backend use <profile>` is **advisory only** (it suggests
`CONTEXT_ENGINE_BACKEND`, it does not persist a selection).

## Setup & provisioning (orientation)

`potpie setup` is the idempotent first-run flow. The CLI builds a `SetupPlan`
(config, storage, daemon, default `default` pot, source registration, skills),
ensures the daemon first when in daemon mode (`host.daemon.ensure()`), and each
backend self-provisions its store through `provision() ->
BackendProvisionResult`, which `potpie/setup/orchestrator.py` wraps into the
`backend.provision` `StepResult`. Re-running is safe; each step is `ensure`-shaped and reports `done |
skipped | not_implemented | failed`.

Two corrections worth stating here:

- The default backend a fresh `setup` provisions is **`falkordb_lite`** (a local
  `redislite` file), not `embedded` or `neo4j`. `postgres`/`chroma`/`hosted`
  cannot be provisioned — `StubGraphBackend.provision()` raises (the prior
  "postgres creates the DB, enables pgvector, runs DDL" claim was aspirational).
- There is no working-tree scan. `setup` registers the repo as a source and
  stops there; the graph is filled by harness-led ingestion. `--scan` is still
  accepted, but no setup step reads it.

The full `setup` flag set and the canonical local journey live in
[cli-flow.md](./cli-flow.md).

A managed host is set up with `potpie host set <url> --token -` (key on
stdin), not by `setup`, which always provisions this machine; see
[cli-flow.md](./cli-flow.md#hosts-commandshostpy).

## Two ledgers: the live event store vs the external seam

The older doc conflated two different things called a "ledger." Keep them
distinct.

1. **The internal event store (the real, live "ledger").** A Postgres-backed
   `context_events` table plus reconciliation-run/work-event/batch tables, where
   every inbound episode/event/record actually lands and gets a lifecycle
   (`adapters/outbound/postgres/…`). Its consumer-state vocabulary is
   **`queued` / `processing` / `done` / `error`** — **not** the six-state
   `pending/processing/applied/failed_retryable/failed_terminal/timed_out` list
   the old doc described. This is what ingestion really uses.
2. **The external Event Ledger seam.** `domain/ports/ledger/client.py
   EventLedgerClientPort` (cursor-advancing `fetch`, read-only `query`,
   `sources`, `health`) + `LedgerCursorStorePort`. The *cursor* concept belongs
   only to this external seam, never to the internal Postgres store.

```mermaid
flowchart LR
  cg_int["internal Postgres event store<br/>context_events · queued/processing/done/error"]
  cg_ext["external Event Ledger seam<br/>EventLedgerClientPort + cursor (stubs)"]
  cg_graph["Graph Service → GraphBackend"]
  cg_int --> cg_graph
  cg_ext -. "pull (roadmap)" .-> cg_graph
```

> **Roadmap (not yet wired):** the external Event Ledger clients
> (`adapters/outbound/ledger/managed_client.py`, `self_hosted_client.py`) are
> **TODO stubs** — `fetch` returns an empty page, `query` raises, `health`
> reports unavailable. Only the in-process `FixtureEventLedgerClient` (tests)
> pages for real, and `LocalLedgerCursorStore` persists cursors. So `potpie
> ledger pull/query/status` exists but is non-functional against any real
> provider today.

The internal store, ingestion entry points, connectors (github/notion only —
scanners are deleted), windowed batching, and the nudge trigger model are owned
by [ingestion-nudge.md](./ingestion-nudge.md).

## Environment switches

| Variable | Effect | Default |
|---|---|---|
| `CONTEXT_ENGINE_BACKEND` / `GRAPH_DB_BACKEND` | Backend profile (first wins) | `falkordb_lite` (host) / `neo4j` (ingestion server) |
| `CONTEXT_ENGINE_HOST_MODE` | `daemon` \| `in_process` | `daemon` |
| `CONTEXT_ENGINE_EMBEDDER` | `none` disables the bundled local embedder | bundled local embedder |
| `CONTEXT_ENGINE_ONTOLOGY_SOFT_FAIL` | Downgrade instead of reject on ontology drift at apply time | off |
| `CONTEXT_ENGINE_AGENT_PLANNER_ENABLED` | Enable service-side LLM reconciliation | **off** |
| `CONTEXT_ENGINE_MAX_CHUNK_EVENTS` | Reconciliation chunk size | 20 |
| `CONTEXT_ENGINE_RECONCILIATION_ENABLED` / `_INFER_LABELS` / `_CONFLICT_DETECT` / `_AUTO_SUPERSEDE` | Reconciliation feature flags | on (planner off) |
| `CONTEXT_ENGINE_ALLOW_UNSIGNED_WEBHOOKS` / `GITHUB_WEBHOOK_SECRET` | GitHub webhook HMAC handling (fail-closed by default) | signed required |

> **Roadmap (not yet wired):** `CONTEXT_ENGINE_AGENT_PLANNER_ENABLED` turns on
> service-side LLM reconciliation, which is parked/non-canonical — canonical
> writes come from harness-authored semantic mutations and the deterministic
> `context_record` path.

## Code map

The active package is `potpie/context-engine/`. Fictional read-path modules from
the old doc ("Ontology Catalog / Identity Resolver / Read View Router") have been
removed — those are **methods on `DefaultGraphService`** plus the declarative
`GRAPH_VIEWS` table and the `ReadOrchestrator`, not standalone components.

| Area | Path |
|---|---|
| Composition roots | `potpie/runtime/composition.py` (`build_local_runtime`); `bootstrap/ingestion_server.py` + `standalone_container.py` (ingestion HTTP root) |
| Public/local runtime boundary | `context_engine.py`; `potpie/runtime/{clients,resource_manager,local_engine}.py` |
| Daemon | lifecycle under `potpie/daemon/`; controller, protocol, server, transport, and coordination under `potpie/runtime/` |
| Service interfaces (ports) | `core/ports/graph_service.py`; `potpie/pots/contracts.py`; `potpie/skills/contracts.py` |
| Graph capability ports | `domain/ports/graph/{backend,mutation,semantic,inspection,analytics,snapshot}.py` + `domain/ports/claim_query.py` |
| Read trunk | `application/services/read_orchestrator.py`, `envelope_builder.py`, `application/readers/`, `domain/agent_envelope.py`, `domain/ranking.py`, `domain/agent_context_port.py` |
| Graph Service (catalog/read/search-entities/mutate/status) | `application/services/graph_service.py` (methods, not separate modules) |
| Named views | `domain/graph_views.py` (`GRAPH_VIEWS`) |
| Write stack | `application/services/{semantic_mutation_validator,semantic_mutation_lowering,graph_workbench,record_to_semantic,reconciliation_validation}.py`; `domain/{semantic_mutations,graph_plans,graph_inbox}.py` |
| GraphBackend adapters | `adapters/outbound/graph/backends/{in_memory,embedded,neo4j,falkordb,stub}_backend.py` + `__init__.py` (`build_backend`) |
| Shared engine room | `adapters/outbound/graph/{cypher,canonical_claim_query,claim_query_analytics,claim_query_semantic,apply_plan,writer_port,in_memory_reader,context_graph_service}.py` + `plan_stores/`, `inbox_stores/` |
| Internal event store | `adapters/outbound/postgres/{reconciliation_ledger,ingestion_event_store,batch_repository,ledger}.py` |
| External Event Ledger seam (stubs) | `domain/ports/ledger/{client,cursor}.py`; `adapters/outbound/ledger/{managed_client,self_hosted_client,cursor_store}.py` |
| CLI (typed-client and root-service routed) | `potpie/cli/main.py` + `potpie/cli/commands/` |
| Ingestion HTTP server | `adapters/inbound/http/` |
| Root capabilities | `potpie/{config,pots,setup,skills}/`; local identity under `potpie/auth/{ports,adapters}/`; cross-capability agent facade at `potpie/agent_context.py` |

### Stable interfaces

| Interface | File | Role |
|---|---|---|
| `GraphService` | `core/ports/graph_service.py` | Graph reads, identity resolution, semantic mutation validate/apply, status, and projections. |
| `PotManagementService` | `potpie/pots/contracts.py` | Pots, active-pot selection, sources, and readiness rollup. |
| `SkillManager` + `AgentTargetPort` | `potpie/skills/contracts.py` | Catalog + per-harness install drift; advisory `SkillNudge`. |
| `GraphBackend` | `domain/ports/graph/backend.py` | Six-capability storage bundle + `profile` + `capabilities()` + `provision()`. |
| `GraphMutationPort` / `ClaimQueryPort` | `domain/ports/graph/mutation.py`, `domain/ports/claim_query.py` | Canonical source of truth (write / read). |
| `SemanticSearchPort` / `GraphInspectionPort` / `GraphAnalyticsPort` / `GraphSnapshotPort` | `domain/ports/graph/{semantic,inspection,analytics,snapshot}.py` | Rebuildable projections. |
| `EventLedgerClientPort` / `LedgerCursorStorePort` | `domain/ports/ledger/{client,cursor}.py` | External Event Ledger pull + per-(pot,source) cursor (clients are stubs today). |
| `ContextEngine` / `EngineClient` / `Daemon` | `context_engine.py`; `potpie/runtime/clients.py`; `potpie/daemon/lifecycle.py` | Immutable context-bound engine, equivalent local/daemon clients, and product-owned lifecycle. |

An unbuilt capability raises `domain.errors.CapabilityNotImplemented` with a
dotted `graph.<profile>.<cap>.<method>` slot, which inbound adapters render as
the structured not-implemented contract — never a bare `NotImplementedError`.

## Extension points

Add behavior at the narrowest service boundary that owns it. CLI adapts user
intent, the daemon hosts services, Pot Management owns pot/source lifecycle,
Graph Service owns graph operations, and `GraphBackend` owns physical storage.

| Change | Put it here | Rule |
|---|---|---|
| Read view | `domain/graph_views.py` (`GRAPH_VIEWS`) + a reader in `application/readers/` | Route through `ReadOrchestrator` and `ClaimQueryPort`; never query stores directly. `READER_BACKED_INCLUDES` must mirror the reader registry (coherence-enforced). |
| Semantic mutation op | semantic DSL + validator + lowering | Add deterministic validation and lowering before exposing it to agents. |
| Entity / predicate | `domain/ontology.py` | One row in `ENTITY_TYPES` / `EDGE_TYPES`; add identity, endpoint rules, freshness, source-of-truth. Coherence guards fail import if it drifts. See [ontology.md](./ontology.md). |
| Graph backend | `domain/ports/graph/` + a backend adapter | Implement the canonical ports, preserve `group_id` pot isolation, pass conformance; declare real caps in `capabilities()`. |
| Skill | `potpie/skills/` catalog + `AgentTargetPort` adapter | Keep skill content harness-neutral; it is not graph data. See [skills.md](./skills.md). |
| Pot behavior | `potpie/pots/` | Preserve the first-setup active `default` pot. |
| Setup / lifecycle step | the component's `provision`/bespoke method + `potpie/setup/orchestrator.py` sequence | Return a `StepResult` (graph backends return `BackendProvisionResult`, which the orchestrator wraps); raise `CapabilityNotImplemented` until built. |

Do not bypass the read trunk, query physical stores from CLI/readers, make a
projection a second source of truth, put service logic in the daemon shell, or
duplicate ontology enums in docs/CLI.

## Invariants

- OSS graph use works with no cloud auth and no mandatory Docker/Neo4j/Postgres
  (the default `falkordb_lite` is embedded).
- The CLI is the user and agent surface.
- The canonical claim store is the only source of truth; semantic/inspection/
  analytics/snapshot are rebuildable projections.
- Every fact is scoped by `pot_id` = `group_id`; no cross-pot federation.
- The canonical write door is `propose` → `commit --verify`; `graph mutate` is a
  legacy wrapper; reads never synthesize answers.
- Pot Management owns pot/source lifecycle; Graph Service owns graph operations;
  the daemon hosts services but holds no business logic.
- Unbuilt capabilities fail-closed with a dotted `CapabilityNotImplemented`
  slot, surfaced as the structured not-implemented contract.

## See also

- [vision.md](./vision.md) — what the Context Graph is and why.
- [ontology.md](./ontology.md) — entities, predicates, views, contract constants.
- [querying.md](./querying.md) — the read trunk, readers, ranking, envelopes.
- [writing.md](./writing.md) — the full write stack (DSL → validate → lower →
  apply), propose/commit, inbox, quality.
- [ingestion-nudge.md](./ingestion-nudge.md) — the internal event store,
  connectors, batching, and the nudge trigger model.
- [cli-flow.md](./cli-flow.md) — the full command surface and the canonical
  journey.
- [observability.md](./observability.md) — logs/traces/metrics/readiness (its
  `graph.propose`/`graph.commit` span names match the corrected write door here).

---

### Context Graph Bench Plan (`docs/context-graph/bench-plan.md`)

---
title: Context graph benchmarks
description: How graph quality is validated across GraphBackend implementations.
---

## Overview

> **Planning / reference artifact.** This is the design intent for the benchmark,
> not a per-run log. The harness itself lives in code at
> `potpie/context-engine/benchmarks/` (`cli.py`/`__main__.py` entrypoint,
> `core/`, `evaluators/`, `use_cases/`, `fixtures/`). Operational notes and
> historical run logs belong in run reports, PRs, or git history — not here.

The benchmark validates **graph quality**, not OSS packaging or latency. The same
scenarios run against the real backends behind the `GraphBackend` contract
(`in_memory`, `embedded`, `falkordb_lite`, `falkordb`, `neo4j`), so an ontology,
reader, or storage change produces a comparable score delta. Source events enter
through explicit harness-style mutation fixtures or a ledger-replay path.

> **Roadmap (not yet wired):** the *managed* backend profile and the *external*
> Event Ledger replay are bench targets but not functional today — managed backend
> routing raises `CapabilityNotImplemented`, and the external Event Ledger clients
> (`adapters/outbound/ledger/{managed,self_hosted}_client.py`) are TODO stubs. The
> bench exercises ledger semantics through the in-process `FixtureEventLedgerClient`
> (see [ingestion-nudge.md](./ingestion-nudge.md)).

## Pipeline

```mermaid
flowchart LR
  cg_fixtures["fixtures + universe"]
  cg_ledger_replay["fixture ledger replay<br/>(roadmap: external ledger)"]
  cg_ingest["ingest / reconcile"]
  cg_graph_backend["GraphBackend"]
  cg_query["potpie graph read / propose"]
  cg_eval["evaluators"]
  cg_report["report"]

  cg_fixtures --> cg_ingest --> cg_graph_backend --> cg_query --> cg_eval --> cg_report
  cg_fixtures --> cg_ledger_replay --> cg_ingest
```

Reads use the shipped V1.5 workbench (`potpie graph read` over named views; see
[querying.md](./querying.md)); writes use `graph propose` → `graph commit --verify`
(see [writing.md](./writing.md)). The benchmark measures whether the engine
stores the right facts, retrieves the right evidence, and gives an agent enough
context to answer well.

> The legacy `ContextGraphQuery`/`ContextGraphStrategy` model (`domain/graph_query.py`)
> is vestigial in the live read trunk and now survives **only** to back this
> harness (`benchmarks/core/local_engine.py`). Treat it as bench plumbing, not a
> production read DTO.

## Goals

- Score by knowledge dimension, not one vague aggregate.
- Stress multi-source ingestion with distractors and conflicting facts.
- Keep a stable synthetic universe so ontology improvements compound.
- Run the same seed/read scenarios through every `GraphBackend` profile.
- Run the same event fixtures through direct ingestion and (roadmap) external
  ledger replay when a scenario needs integration semantics.
- Produce clear score deltas when readers, ontology, reconciliation, or storage
  change.

Non-goals:

- Latency/throughput benchmarking.
- LLM model benchmarking.
- Replacing unit tests.
- Production real-data parity.

## Use Cases

| Code | Dimension | What it tests |
|---|---|---|
| `PREF` | Project preferences | Normative rules: logging, errors, file layout, security, testing. |
| `INFRA` | Architecture/topology | Services, environments, dependencies, deploy path, owners. |
| `TIME` | Timeline | What changed when, by whom, and in what order. |
| `BUG` | Debug memory | Prior failures, root causes, fixes, recurrence patterns. |
| `COMBO` | Composite | A scenario spanning two or more dimensions. |

## Scoring

Each scenario has three primary axes:

| Axis | Meaning |
|---|---|
| Ingestion | Did the graph contain the expected entities/claims and avoid bad ones? |
| Retrieval | Did named read views surface the expected evidence and avoid distractors? |
| Agent use | Did the agent answer (or proposed update) satisfy the rubric using surfaced context? |

Coverage and precision are reported alongside ingestion and retrieval.

Default weights:

| Use case | Ingestion | Retrieval | Agent use |
|---|---:|---:|---:|
| `PREF` | 20 | 40 | 40 |
| `INFRA` | 30 | 40 | 30 |
| `TIME` | 40 | 30 | 30 |
| `BUG` | 25 | 35 | 40 |
| `COMBO` | 30 | 35 | 35 |

Pass criteria:

- axis score meets its threshold;
- weighted scenario score meets `pass_score`;
- at least 80% of scenarios in a use-case bucket pass;
- each declared dimension in a composite scenario scores at least 60.

### The invariant (schema-independent) judge

Because reads return a **pure evidence envelope with no server-side answer
synthesis** (the agent reasons over the `AgentEnvelope` — see
[querying.md](./querying.md)), agent-use grading defaults to a
**schema-independent invariant judge** (`benchmarks/evaluators/llm_judge_invariant.py`).
A single LLM call is given the scenario's raw input events plus the agent's full
answer and grades on four engine-agnostic dimensions:

| Dimension | Asks |
|---|---|
| `faithfulness` | Every claim in the answer is supported by the events — no fabricated ids/people/facts. |
| `coverage` | The answer surfaces the events/facts essential to the user's question. |
| `clarity` | Structured so a human quickly understands what happened. |
| `usefulness` | Concretely helps the user diagnose/debug/follow a convention/plan a change. |

Aggregate is the weighted mean (default 30/30/20/20 — faithfulness + coverage
dominate so cosmetic clarity/usefulness can't compensate for hallucination or
omission). This decouples the agent-use axis from fixture ids and the engine's
current include vocabulary, so the score doesn't move just because the ontology
or query layer changed. The invariant judge is **on by default** for `run`; opt
back into the legacy per-rubric synthesis judge with `--rubric` (deprecated, and
it requires a server-synthesised answer). Ingestion and retrieval axes stay in
the report as diagnostics but no longer gate.

## Scenario Shape

```yaml
id: bug_redis_connection_flap
use_case: BUG
dimensions: [BUG, TIME]
difficulty: medium
source_mix: dual
event_path: ledger_replay
universe: acme

seed:
  - { event: universe/acme/services.yaml, at: "-365d" }

ingest:
  - { event: linear/issue_create__OPS-218.json, at: "-90d", tags: [signal] }
  - { event: github/pr_merge__1234.json, at: "-89d", tags: [signal] }

distractor_events:
  - { event: github/pr_merge__noise_*.json, at: "-90d..0d", count: 10 }

query:
  subgraph: debugging          # the prior-occurrences view lives under `debugging`
  view: prior_occurrences
  scope: { services: [inventory-svc] }

retrieval_assertions:
  must_cite_event_id: [linear/issue_create__OPS-218.json]
  must_not_cite_event_id: [github/pr_merge__noise_001.json]

judge:
  pass_score: 70
  criteria:
    - name: surfaces_prior_incident
      weight: 30
      dimensions: [BUG]
      prompt: "..."
```

The `signal`-tagged events are the ones the invariant judge sees as ground truth.
A single easy/medium scenario per dimension carries `light: true`, marking it for
the curated quick subset (`core/scenario.py`).

## Synthetic Universe

The default universe is `acme`: a small fictitious company with stable services,
repos, people, environments, ADRs, runbooks, and incidents.

```mermaid
flowchart TB
  cg_acme["Acme universe"]
  cg_services["services + repos"]
  cg_people["people + owners"]
  cg_adrs["ADRs + runbooks"]
  cg_events["PRs, issues, deploys, alerts, chat"]

  cg_acme --> cg_services
  cg_acme --> cg_people
  cg_acme --> cg_adrs
  cg_acme --> cg_events
```

Source types: GitHub, Linear, Slack, Notion, repo docs, alerting, deploy events,
and (roadmap) external Event Ledger replay.

Difficulty controls source mix and distractor ratio:

| Difficulty | Shape |
|---|---|
| `easy` | Single source, low noise, recent. |
| `medium` | Two sources, moderate distractors, historical context. |
| `hard` | Three+ sources, heavy near-miss distractors. |
| `adversarial` | Conflicting facts, out-of-order arrival, near-duplicates. |

## Running

The harness is a small CLI (`python -m benchmarks ...` / `benchmarks/cli.py`):

| Command | What it does |
|---|---|
| `run` | Full/tiered run; invariant judge on by default (`--rubric` to opt out). |
| `run-light` | Curated 5-scenario subset (one per dimension), concurrency 5, invariant judge — the PR-check tier. |
| `list [--grid]` | List discovered scenarios (or a `use_case × difficulty` matrix). |

`--local` (or `POTPIE_BENCH_INPROCESS=1`) runs the engine in-process — no
`:8001` HTTP server and no Celery worker, reconciling inline — and is the
recommended mode for light runs. `--skip-judge` does cheap dry runs without the
LLM axis.

## Report

The report should show:

- per-use-case score table;
- per-difficulty curve;
- per-source-mix curve;
- composite dimension rollups;
- baseline diff against a prior report.

Example panel:

```text
| Use case | N | Aggregate | Ing | Ret | Agent | Cov | Prec | Pass |
|----------|--:|----------:|----:|----:|------:|----:|-----:|-----:|
| PREF     | 6 |      72.4 | 60  | 78  |    76 | 84  |  91  | 4/6  |
```

## Current Status

The benchmark currently has:

- a scenario schema for dimensions, difficulty, source mix, seed events,
  distractors, the `light` quick-tier flag, and per-dimension judge criteria;
- an `acme` synthetic universe;
- quick-tier scenarios across `PREF`, `INFRA`, `TIME`, `BUG`, and `COMBO`;
- the schema-independent **invariant judge** (default for `run`) plus the
  **`run-light`** 5-scenario PR-check subset;
- smoke and fixture-validation paths.

## Next Work

1. Keep the quick tier (`run-light`) small enough for PR checks.
2. Keep the extended tier for nightly/backend comparison.
3. Add conformance runs for every `GraphBackend` profile.
4. Add external Event Ledger replay scenarios (GitHub/Linear-style event
   ordering, consumer-cursor replay, query/filter behaviour, retryable failures,
   timed-out leases, out-of-order arrival) once the ledger clients land.
5. Use baseline diffs as the main regression signal.
6. Add a real-data/redacted corpus only after the synthetic bench is stable.

## See also

- [Context Graph](./index.md) — docs index and the two-profile overview.
- [querying.md](./querying.md) — the read trunk, `AgentEnvelope`, named views, ranking.
- [writing.md](./writing.md) — the propose → commit write door the bench drives.
- [ingestion-nudge.md](./ingestion-nudge.md) — the internal event store vs the external Event Ledger seam.
- [ontology.md](./ontology.md) — subgraphs, views, truth classes the scenarios assert against.

---

### Context Graph Cli Flow (`docs/context-graph/cli-flow.md`)

---
title: CLI flow and command reference
description: "Full potpie CLI command catalog, flags, and the canonical journey."
---

## Overview

> Status: reflects the canonical Context Runtime boundary, last reviewed 2026-08-21.

This is the **command reference** for the `potpie` CLI — the full, grouped surface
with flags, the shared plumbing every command goes through, and the canonical
journey. It is the one doc that may restate flags in full; conceptual depth lives
in the sibling docs linked under each group and in [See also](#see-also).

This file lives at repo-root `docs/context-graph/cli-flow.md`. Product CLI,
runtime, daemon, and root capability code lives under repo-root `potpie/`; engine
domain code lives under `potpie/context-engine/`.

## One CLI for humans and agents

There is no separate human-vs-agent API. Both people and coding harnesses drive
the same `potpie` CLI.
`potpie/cli/main.py build_app()` is the single console entrypoint
(`[project.scripts]`): one Typer root whose `@app.callback` exposes four global
options, with the rest of the surface assembled from top-level registrars and
`add_typer` sub-apps. Engine operations route through an `EngineClient`; root
capability commands use finite root-owned services.

```mermaid
flowchart LR
  cf_user["user / agent"]
  cf_cli["potpie CLI<br/>main.py build_app()"]
  cf_common["commands/_common.py<br/>get_engine_client • get_root_runtime • contract"]
  cf_shell["LocalEngineClient / DaemonEngineClient<br/>typed finite operations"]
  cf_svc["ContextEngine + root capability services<br/>pots • setup • skills • daemon • ledger"]
  cf_ports["GraphBackend + capability ports"]

  cf_user --> cf_cli --> cf_common --> cf_shell --> cf_svc --> cf_ports
```

### Global options (root `@app.callback`)

| Option | Effect |
|---|---|
| `--json` | machine-readable output for scripts/agents (stable, additive fields) |
| `--verbose` / `-v` | verbose diagnostics |
| `--host local\|managed` | run this one command against that host without changing the active one; applied last, never persisted. Naming a managed host that is not configured (or unusable) is refused with `validation_error` rather than answered locally. It may also follow a command that has no `--host` of its own (`potpie status --host managed`). See [Hosts](#hosts-commandshostpy) |
| `--version` | print `potpie <version> (<short rev>[, dirty])`, the engine version and the interpreter, then exit; with `--json`: `{name, version, build: {rev, dirty, built_at}, engine: {name, version}, python, executable}` |

## Shared plumbing (`commands/_common.py`)

Every command is wrapped by the same three helpers, so the contract below holds
uniformly across the surface.

- **`get_engine_client()`** — routes to the invocation's origin first (see
  [Hosts](#hosts-commandshostpy)). On the local origin it returns
  `DaemonEngineClient` by default after canonical discovery and an
  authenticated handshake; with `CONTEXT_ENGINE_HOST_MODE=in_process`, it
  returns `LocalEngineClient` over the same typed operation handlers and
  Resource Manager. On the managed origin it returns a `RemoteEngineClient`
  for that host (`potpie.runtime.remote`), with no handshake or ticket.
- **`pot_directory()`** — the current origin's `PotDirectory`
  (`potpie.pots.directory`): `LocalPotDirectory` over the in-process pot
  service, or `ManagedPotDirectory` over the host's `host.*` operations. Pot,
  source, grant, `whoami` and `status` commands read and write pots through it.
- **`get_root_runtime()` and finite service accessors** — provide Potpie-owned
  setup, auth, configuration, pot/source, skills, ledger, and lifecycle services
  without adding them to `ContextEngine`.
- **`contract()`** — the error boundary that maps outcomes to exit codes and emits
  structured JSON errors (`code`, `message`, `detail`, `recommended_next_action`):

  | Exit | Meaning |
  |---|---|
  | `0` | success |
  | `1` | command / validation failure |
  | `2` | daemon / API / dependency unavailable (incl. `CapabilityNotImplemented`, `ContextEngineDisabled`) |
  | `3` | partial / degraded result |
  | `4` | auth / permission failure |

- **`resolve_pot_id(...)`** — pot-scope resolver, precedence:
  explicit `--pot` **>** repo-default binding **>** registered-repo match (active
  pot wins ties, else `ambiguous_pot`) **>** active pot, else `no_active_pot`.
  `source add` passes `infer_from_repo=False` (registration never infers a pot).
  Archived pots never answer: an explicit ref that names only an archived pot
  fails with `pot_archived`, and a repo default pointing at one reads as unset.
  The registered-repo match reads the pot service's repo→pot index
  (`list_repo_sources`) in one call and matches the working tree client-side;
  the typed engine's repository selector uses the same index.

`emit()`/`fail()` render the human and `--json` shapes. All commands support
human output by default and `--json` for scripts/agents.

## Command groups & code slots

Surface assembled via top-level registrars (`query`, `bootstrap`, `auth`, `ui`)
and `add_typer` sub-apps. Note the corrections vs older docs: there is **no
`commands/backend.py`** — the `backend` and `timeline` apps are defined in
`commands/graph.py`; and there is **no `graph admin`** command group.

| Group / commands | Code slot | Routes to |
|---|---|---|
| `resolve` `search` `record` `status` | `commands/query.py` | `EngineClient` typed operations |
| `setup` `doctor` `whoami` `use` `config` | `commands/bootstrap.py` | finite root services plus `EngineClient` readiness where needed |
| `login` `logout` + provider groups (`github`/`git`/`linear`/`jira`/`confluence`/`auth`) | `commands/auth.py` | root-owned Firebase/API-key auth + integration read clients |
| `ui` | `commands/ui.py` | ensures daemon, opens read-only graph explorer |
| `pot` `source` | `commands/pots.py` | the origin's `PotDirectory` (local pot service or a managed host's `host.*` operations) |
| `daemon` | `commands/daemon.py` | root lifecycle service and `DaemonController` |
| `ledger` | `commands/ledger.py` | root `LedgerService` (clients are stubs — roadmap) |
| `graph` (+ nested `inbox`, `quality`, `bulk`) | `commands/graph.py` | finite `EngineClient` operations; root backend administration where required |
| `graph` commit history (`journal-status`, `commits`, `commit-show`, `revert`, `rollback`, `apply-preview`, …) | `commands/graph_commits.py` | finite `EngineClient` commit operations |
| `timeline` | `commands/graph.py` | typed graph read operation |
| `backend` | `commands/graph.py` | root backend administration service |
| `skills` | `commands/skills.py` | root `SkillManager` |
| `resource` (+ nested `index`) | `commands/resource.py` | finite `EngineClient` resource operations |
| `host` | `commands/host.py` | host registry (`potpie.config.host_registry`); `set --check` probes the host |
| `cloud` | `commands/cloud.py` | managed sync — **all raise `CapabilityNotImplemented`** (roadmap) |

The async ingestion pipeline behind the HTTP API keeps a **separate**
composition root (`bootstrap/ingestion_server.py`, default backend `neo4j`) — see
[architecture.md](./architecture.md) and [ingestion-nudge.md](./ingestion-nudge.md).

---

## Top-level commands

The V1 agent wrappers (`resolve`/`search`/`record`) and `status` ride the same
graph internals as the workbench; they are not a "legacy V1 surface waiting on V2."

```bash
potpie resolve <task> [--intent <name>] [--include <csv>] [--mode fast|balanced|verify|deep] [--limit 12] [--pot <ref>]
potpie search  <query> [--intent <name>] [--include <csv>] [--limit 12] [--pot <ref>]
potpie record  --type <kind> --summary <text> [--detail <k=v> ...] [--scope <k:v>] [--pot <ref>]
potpie status  [--intent <name>] [--harness claude] [--pot <ref>]

potpie setup   [--repo .] [--pot default] [--agent claude] [--backend <profile>] \
               [--embeddings auto|sentence-transformers|local|none] [--embedding-model <name>] \
               [--dry-run] [--yes/-y] [--daemon | --in-process]
potpie doctor
potpie whoami
potpie use     <ref> [--local | --managed]
potpie config  get <key>
potpie config  set <key> <value>
potpie config  unset <key>
potpie login   [--api-key/-k <key>] [--url/-u <url>]
potpie logout
potpie ui      [--open/--no-open] [--pot <ref>]   # <ref> may be managed:<id>
```

- **`resolve` / `search` / `record`** → the corresponding typed `EngineClient`
  operations.
  Read `--limit` caps the final ranked envelope across all searched families;
  metadata reports returned and omitted counts per family. Readers still use
  the same limit as their candidate budget, so no exact global count is implied.
  Agent evidence has a 32 KiB serialized response budget; omitted item and field
  counts explain trimming. Narrow the family or follow a returned entity or
  resource ID to fetch the relevant detail.
  `--include` replaces the intent's default families; `--mode` rides in metadata
  only — it does not change the read path in V1.5 ([querying.md](./querying.md)).
  `--mode`, `--include` and `--intent` are checked against their closed
  vocabularies: an unrecognised value is refused rather than normalised, because
  normalising it changes the depth of the read or which reader families answer,
  silently.
  `record --type` accepts the structured record types (preference/policy/bug_pattern/
  fix/verification/decision) plus a closed set of free-form note types (`--type`
  help lists them); it goes through semantic validation and
  the record→semantic bridge ([writing.md](./writing.md)). Those schemas validate
  fields that `--summary` cannot carry — `decision` needs `rationale`, `preference`
  needs `policy_kind` — so `--detail <key>=<value>` supplies them; repeat the flag,
  and repeat a key to build a list field (`alternatives_rejected`, `affects_refs`).
  A record the graph service **refuses** exits `1` and reports `accepted: false`
  with the store's reason in `detail`.
- **`status`** — context data-plane readiness combined with root runtime state: daemon, backend,
  pot, quality, and skill state. On a managed host the pot half is `host.pot_status`,
  the data plane is the host's, `profile` is `managed`, and skill readiness is still
  this machine's. `potpie status --host managed` is the global option typed after
  the command; status has no `--host` flag of its own.
  `--verify` is rejected here — it moved to `potpie auth status --verify`, the
  explicit integration-auth report.
- **`setup`** — idempotent first-run that builds a `SetupPlan`
  (config/storage/daemon/active `default` pot/source registration/skills). `--backend`
  picks the GraphBackend profile (default `falkordb_lite`) and refuses an unknown
  profile with `validation_error`; `--embeddings` picks the local embedder (default
  `auto`: sentence-transformers when the `embeddings` extra is installed, otherwise
  the bundled hashing embedder) and `--embedding-model` the model setup prepares;
  `--daemon`/`--in-process` selects host mode
  (daemon mode calls the root lifecycle service first); `--dry-run` returns a preview
  without executing. `--pot` only overrides the initial pot name.
  Setup never scans the working tree: repository knowledge is written by
  harness-led ingestion (`potpie graph propose`/`commit`, the
  `potpie-repo-baseline` skill). `--scan` is still accepted, but no step reads it.
- **`doctor`** — local diagnostics composed from `backend.capabilities()` +
  `backend.mutation.readiness()` + `daemon.status()` + `ledger.status()` + the
  resource store and retrieval index status (`resources`, `resource_index`); also
  reports `effective_current_repo_pot` and `repo_default_pot` (the repo→pot
  routing resolution for the current directory). A `host` block names the
  origin; on a managed host it adds the host's handshake and `daemon.status`
  answer (`reachable`, `instance_id`, `version`, `build`, `protocol`,
  `operations`, `problem`), the backend rows come from the host's data plane,
  and every row degrades on its own. The `daemon`, `cli_install` and
  embedded-server rows are always this machine's.
- **`config get|set|unset`** — reads and writes `<home>/config.json`. `set`
  accepts only the known keys (`config --help` and `config list` name them) and
  checks the value of a key that takes a closed set: `resource_index` takes an
  index profile, `graph.protocols` takes `on` or `off`. `unset` accepts any key,
  so a value stored before the catalog was enforced can still be removed.
  `graph.protocols` switches on the optional protocol ontology
  ([ontology.md](./ontology.md), *Optional protocol extension*). It is **off
  by default**: an absent, blank or unrecognised value reads as off. The local
  runtime reads it once, when it composes, so `config set`/`unset` of that key
  reports `restart_required: true` and a running daemon keeps its old setting
  until `potpie daemon restart`. Turning it off hides the protocol types and view
  and keeps the data. There is no environment switch for it.
- **`whoami`** — who the current host believes you are: local OSS reports a
  `none` identity; a managed host answers `host.whoami` (`subject`, `mode`,
  `detail`).
- **`use <ref>`** — alias for `pot use`. `--local` / `--managed` select the host
  exactly as qualifying the ref does (`local:<ref>` / `managed:<ref>`); both at
  once, or a flag that contradicts the ref's prefix, is a `validation_error`.
- **`login` / `logout`** — root-owned Firebase browser session or API-key store.
  Flags are `--api-key/-k` and `--url/-u`.
- **`ui`** — ensures the daemon, discovers its `base_url`, opens
  `<base>/ui?host=<origin>[&pot=<id>]&k=<code>`, the read-only graph explorer.
  The local daemon serves the explorer for both hosts: `--pot managed:<ref>`
  resolves the pot on the managed host and puts `host=managed` in the URL, and
  the daemon reads that host with the key from the host registry, so the key
  never reaches the browser. `k` is the single-use handoff code. JSON:
  `{url, pot_id, origin, warning}`.

### Provider auth groups (root, `commands/auth.py`)

These manage credentials for the agent's own integration reads (jira/linear are a
CLI + agent flow, **not** Potpie connectors — see [ingestion-nudge.md](./ingestion-nudge.md)).

```bash
potpie github  login | logout | repos
potpie linear  login | logout | ls | select
potpie jira     login | logout | ls | select
potpie confluence login | logout | ls | select
potpie auth     status [--verify] | logout # integration auth report + logout
```

`git` is a hidden alias group. `auth status [--verify]` is the explicit local
integration-auth report (`--verify` runs a lightweight API check); the rest of
the `auth` group is deprecated (logout + hidden `revoke` + provider mirrors).

---

## Pots & sources (`commands/pots.py` → `host.pots`)

A Pot is the unit of tenancy/isolation; the pot id **is** the storage `group_id`.
Local setup creates and activates a `default` pot.

```bash
potpie pot list [--local | --managed | --all] [--archived]
potpie pot info
potpie pot create <name> [--repo .] [--use] [--no-default]
potpie pot use    <ref> [--also-default-for-current-repo]
potpie pot rename <ref> <new-name>
potpie pot reset  [<ref>] [--confirm]
potpie pot archive <ref> [--confirm]
potpie pot grant  <ref> <actor-id> [--role reader|writer|admin]   # managed host only
potpie pot revoke <ref> <actor-id>                               # managed host only
potpie pot grants <ref>                                          # managed host only

potpie pot linked  [--repo .] [--summary]
potpie pot default show | set | clear [--repo .]

potpie source add    <kind> <location> [--name <n>] [--pot <ref>] [--default/--no-default]
                     # kind: repo | linear | jira | confluence | notion | url
potpie source list   [--pot <ref>]
potpie source status [<id>] [--pot <ref>]
potpie source remove <id> [--pot <ref>]
```

- **`pot list`** lists every configured host by default; `--local` / `--managed`
  narrow it, and naming both (or `--all`) lists both, so a managed host that is
  missing or down is stated rather than merely absent. JSON:
  `{pots: [{id, name, active, archived, current, origin}], active_origin,
  unavailable?}`. `active` is that host's own pointer; `current` marks the pot
  commands act on (the active host's active pot). Each host degrades on its
  own (`unavailable.<origin>` names why); when no listed host answers, the
  command fails with `unavailable` (exit `2`) instead of an empty listing.
  `--managed` with no managed host configured is `not_implemented` (exit `2`).
- **`pot info`** — `{active_pot, current_repo}` for the current host;
  `current_repo` is the repo→pot routing for this directory (`repo`,
  `active_pot_id`, `default_pot_id`, `effective_pot`, `reason`, `status`,
  `candidates`). On a managed host the repo default is the host's, and only a
  repository identified by its git remote has one.
- **`pot use`** selects the pot on the host its ref targets and then persists
  that host as the active origin. With `--also-default-for-current-repo` the
  repo default is bound on the same host after the selection succeeds, and the
  origin is persisted only then. On a managed host that needs a git remote.
  JSON: `{id, name, origin, repo_default_set, current_repo, warnings}`.
- **`pot grant` / `revoke` / `grants`** share a managed host's pot with other
  actors (`reader` reads, `writer` writes, `admin` also does destructive work
  and sharing; the host enforces it). On the local host they refuse with
  `unsupported`: the local daemon is single-user. JSON: `grant` →
  `{pot_id, actor_id, role}`, `revoke` → `{pot, actor_id, revoked}`, `grants` →
  `{pot, grants: [...]}`.
- **Local-only lifecycle.** `pot rename`, `pot archive`, `source status <id>`
  and `source remove` are served by the local host only; on a managed host they
  are refused as `not_implemented` before anything runs.
- **`pot reset`** is the destructive per-pot wipe — note there is **no
  `graph reset`** command. The CLI resolves the exact pot once, binds
  confirmation to that context, and dispatches `ResetContextRequest` through
  the selected daemon or in-process Context Engine. Pot metadata services do
  not open or reset graph backends. Once the graph reset succeeds, the pot's
  stored documents are purged too, and `resources_purged` reports the resource
  store's answer (`null` when no store is composed); see
  the resource store in [architecture.md](./architecture.md).
- **`pot archive`** clears the pot's graph state with the same confirmed
  `ResetContextRequest` as `pot reset`, then retires the pot. The reset comes
  first, so a failed reset leaves the pot live rather than hiding data nothing
  can clear. It is idempotent: on an already-archived pot it clears the graph
  state again, leaves the pot archived, and reports `already_archived: true`
  (still behind `--confirm`). A live pot wins a name it shares with archived
  pots; an archived pot is always reachable by its id, and a name shared by
  several archived pots is refused as `ambiguous_pot`.
- **`archived` is a terminal lifecycle state, enforced.** Archived pots are
  hidden from `pot list` (a footer names the count; `--archived` shows them
  marked `~`, and every JSON row carries `archived`), and `pot use` / `rename` /
  `reset` / `default set`, `source add` and any `--pot` refuse them with
  `pot_archived` (exit `1`). Their repo sources drop out of repo→pot matching,
  the explorer UI neither lists nor selects them, and
  `pot create <archived-name>` starts a fresh pot. At the typed engine
  boundary a name never selects an archived pot, and an archived pot's id is
  authorized for `reset_context` only.
- **One name, one pot.** Pot names are unique among live pots and may not
  equal any pot id (refs resolve against both): `rename` refuses either
  collision, and `create` refuses an id-shaped name, with `pot_name_conflict`.
  A blank name is a `validation_error`. `create` stays idempotent (reusing a
  live pot by name is what makes `setup` re-runnable) and reports
  `created: false` when it reused one.
- **`pot linked` / `pot default`** manage the repo→pot binding consumed by
  `resolve_pot_id`. `pot linked --summary` skips per-pot graph counts for a faster
  repo-routing summary. `pot create --repo <r>` registers the repo and makes the
  new pot its default (`--no-default` opts out), and
  `pot use --also-default-for-current-repo` sets the current repo's default in
  the same step (otherwise the CLI warns when the repo default and the selected
  pot diverge).
- **`source status`** with no ID prints a per-pot summary of all sources; with an
  ID it reports that single source.
- **`source add <kind> <location>`** is registration only (no scan/ingest);
  registering a repo also sets the repo default. Repo-baseline ingestion is
  harness-led via skills ([skills.md](./skills.md)), not a scanner. `kind` is a
  closed set (`cli/source_kinds.py`), because a kind with no handler used to
  exit 0 and write a row nothing reads: git hosts (`github`/`gitlab`/`gitbucket`)
  canonicalize to `repo` — the kind repo-default matching and `source status`
  key on — and the canonicalization is reported as `requested_kind`; document
  kinds (`pdf`/`spreadsheet`/`markdown`/…) exit 1 with
  `source_kind_is_a_document` pointing at `resource import`
  (see *Resources* below); anything else exits 1 with
  `unknown_source_kind`. `--default` is repo-only: passing it with another kind
  fails with `repo_default_not_applicable`.
- **`source remove`** drops the registration only — it does not purge documents or
  graph claims (a source row is not a key into the resource store).

---

## Daemon (local infra)

```bash
potpie daemon start | status | logs [--tail N] [--since 15m|ISO-8601] [--follow] | restart | stop
```

- **`daemon`** (`commands/daemon.py` → `host.daemon`) — local recovery tooling, not
  onboarding steps. `DaemonStartError` → exit 2.
- **`daemon status`** names the build the running daemon serves (`version`,
  `build: {rev, dirty, built_at}`) and sets `stale` when that rev differs from this
  CLI's (`null` when either side has no rev). A daemon outlives the install that
  started it, so after an upgrade `stale: true` means `potpie daemon restart`.
  A daemon from before build reporting has neither key.
  It exits `0` only when the daemon answers its authenticated handshake. A
  daemon that is down, or whose process exists but does not answer, is
  `daemon_unavailable` (exit 2); the JSON payload keeps the status fields
  alongside the error keys. A daemon whose operation catalog differs from this
  CLI's (an older build left running after an upgrade) reports
  `compatible: false` and `stale: true` and exits 2 with `daemon_incompatible`
  and a `potpie daemon restart` hint; `daemon stop` and `daemon restart` replace
  it through a control-only handshake. That handshake works only when both
  builds have it: a daemon from a build without it, such as potpie 2.0.1,
  still needs a manual stop.
- **`daemon logs`** prints the last 200 lines by default (`--tail 0` for the whole
  file). `--since` takes an ISO-8601 time or an age such as `15m`; `--follow`
  streams new lines until interrupted (one JSON object per line with `--json`).
- Supporting-service admin CLI (`potpie service …`) is not part of the OSS surface;
  the detached daemon does not expose a compatible `/admin/services` discovery
  contract for those commands.

---

## Event Ledger (`commands/ledger.py` → `host.ledger`)

```bash
potpie ledger status
potpie ledger sources list [--pot <ref>]
potpie ledger query  [--source <id>] [--type <kind>] [--since <time>] [--until <time>] [--limit 100] [--pot <ref>]
potpie ledger use    managed [--org <id>] | self-hosted <url> [--org <id>]
potpie ledger pull   --source <id> [--filter <expr>] [--pot <ref>]
potpie ledger disconnect
```

The **external** Event Ledger is a separate managed-or-self-hostable source-event
service that the graph *pulls from* (it is never the graph's source of truth).
`ledger query` is read-only history (no cursor advance); `ledger pull` advances the
per-`(pot,source)` cursor. `--filter` is reserved/unused; `ledger use` writes config
(runtime rebinding is roadmap).

> **Roadmap (not yet wired):** the external ledger clients
> (`adapters/outbound/ledger/managed_client.py`, `self_hosted_client.py`) are TODO
> stubs — `ledger pull/query/status` exist but are **non-functional against any real
> provider today**. Do not confuse this with the **internal** Postgres event store
> (the live "ledger", lifecycle `queued/processing/done/error`) described in
> [ingestion-nudge.md](./ingestion-nudge.md).

---

## Hosts (`commands/host.py`)

The CLI talks to two kinds of context host. The **local** host is this
machine's daemon (or in-process runtime) and needs no configuration. A
**managed** host is a hosted context-graph service that serves the same typed
operations at `POST <base>/v1/operations` over HTTPS with a per-actor API key.

```bash
potpie host list
potpie host set <url> [--token -|''] [--check/--no-check] [--allow-insecure]
potpie host clear
potpie host use local|managed
```

- **Registry.** `host set` stores the URL and key in `<home>/cli_hosts.json`
  (`0600`, replaced atomically; an unreadable file fails loudly and `host
  clear` moves it aside to `cli_hosts.json.corrupt`). `POTPIE_MANAGED_URL`
  (with `POTPIE_MANAGED_TOKEN`) outranks the file for one-off runs.
- **The key never travels on argv.** `--token -` reads it from stdin (a hidden
  prompt on a terminal), `--token ''` clears a stored one, and a literal
  `--token <key>` is refused with `validation_error` because it would be
  visible in the process list and shell history.
- **TLS.** `https://` is required, except plain `http://` for a loopback host or
  for exactly one opted-in `host[:port]` (`--allow-insecure`, or
  `POTPIE_ALLOW_INSECURE_HOST` for `POTPIE_MANAGED_URL`). Every command that
  sends the key over that opt-in prints a one-line warning on stderr, never on
  stdout.
- **`--check`** (the default) runs a handshake and `host.pot_list` before
  saving, so a typo never replaces a working endpoint. JSON: `{origin,
  endpoint, checked, pots?, shadowed_by_env, allow_insecure_host}`.
- **`host list`** JSON: `{hosts: [{origin, configured, endpoint, problem,
  active, allow_insecure_host}], active_origin, selected_origin,
  persisted_origin, degraded, problem}`.

**Which host a command talks to.** The origin of one invocation is, in order:
the prefix of a qualified pot ref (`managed:<ref>`, `local:<ref>`), else
`--host`, else the pointer `host use` (or `pot use`) persisted. A persisted
`managed` pointer degrades to `local` while no managed host is configured; an
explicit target (`--host managed`, a `managed:` ref) never degrades and is
refused instead. On the managed origin:

- engine commands (`resolve`, `search`, `record`, `status`, `graph …`,
  `resource …`, `timeline`) go to the host through `RemoteEngineClient`;
- pot, source, grant and `whoami` commands go through the host's `host.*`
  operations;
- without `--pot`, the host selects the pot for the current repository. The
  repository is sent only when it is identified by a git remote; a
  path-derived identity is never sent, and the host then uses your active pot.

`daemon`, `skills`, `setup`, integration `auth` and harness configuration are
always this machine's. `--host managed setup` is refused: setup provisions the
local machine.

**Bare refs** resolve on the current origin only, never on the other host
(names are per-host labels). When a bare ref is not found there, the
`pot_not_found` refusal names the qualified ref of any match the other
configured host holds, e.g. `'managed:api'`.

**An older host.** A managed host that lacks an operation or a field this
client sends answers with a precise refusal; the CLI reports it as
`not_implemented` (exit `2`) naming the operation and fields, and everything
else the host supports keeps working. Results from a newer host decode
leniently.

**Python façade.** `potpie.runtime.connect()` returns a `HostConnection` for
the registry's managed host: `engine(selector)` (an async `EngineClient`),
`pots` (a synchronous `PotDirectory`, usable from any thread) and
`extension(name, request, result_type)` for host extension operations.
Synchronous code runs any of these with `connection.run(awaitable)` on one
shared background loop, and closes with `connection.close()`. `pots` serves a
listing from its cache for at most five seconds (`max_age_s`; `invalidate()`
or `listing(fresh=True)` read past it). `potpie.runtime.remote.from_agent_request`
turns an agent-door resolve, search or record request into the selector and
engine request `engine()` takes.

## Cloud (`commands/cloud.py`)

```bash
potpie cloud login | status | push [--pot <ref>] | pull [--pot <ref>]
potpie cloud skills sync [--agent <id>]
```

> **Roadmap (not yet wired):** every `cloud` command raises
> `CapabilityNotImplemented`. Managed hosts are reached through the `host`
> group and qualified pot refs instead (see [Hosts](#hosts-commandshostpy)).

---

## Resources (`commands/resource.py`)

```bash
potpie resource import <dir> --doc <slug> [--source-ref <uri>] [--source-kind <fmt>] [--pot <ref>]
potpie resource get    <id> [<id>...] [--with-neighbors] [--full] [--pot <ref>]
potpie resource list   --doc <slug> [--section <slug>] [--limit 10] [--full] [--pot <ref>]
potpie resource rm     <slug> [--confirm] [--pot <ref>]
potpie resource index  status [--pot <ref>]
potpie resource index  build [--doc <slug>] [--wait] [--pot <ref>]
potpie resource index  rebuild [--doc <slug>] [--confirm] [--pot <ref>]
```

Document payloads: the bytes the graph only points at. Each command is one typed
engine operation, so the in-process runtime and the local daemon answer
identically; the resource store in [architecture.md](./architecture.md) owns the data model, the
retrieval index and the lifecycle.

- **`import`** reads the chunk directory an extraction script produced
  (`<section>/<seq>.txt` plus `meta.json`) on the caller's side and ships its
  contents, never a path. Bytes land first, then the `Document`/`DocumentSection`
  structure goes to the graph through the semantic-mutation door. A re-import
  publishes a new revision and keeps the prior ones.
- **`get`** resolves up to 128 `potpie://res/<doc>/<section>/<seq>[@rev<N>]` ids
  straight to file reads: no graph query, no embedding. `--with-neighbors` adds
  the chunks either side within the same section.
- **`list`** returns up to ten sections in manifest order with the total and
  omitted section counts; `--section` narrows to one. `get` and `list` have
  32 KiB response budgets and name what they omitted; `--full` bypasses the
  budget for an explicitly chosen chunk or section, and credential-like metadata
  stays redacted in both modes.
- **`rm`** is destructive and needs `--confirm`; without it, a `--json` or
  non-interactive call fails with `destructive_confirmation_required`.
  `index rebuild` also needs `--confirm`, because re-embedding is slow, not
  because anything can be lost.
- Store failures keep their own stable `code` (`resource_chunk_too_large`,
  `resource_not_found`, `resource_slug_invalid`, `resource_revision_ambiguous`,
  …) rather than a flat `validation_error`; they all exit `1`.

---

## Skills (`commands/skills.py` → `host.skills`)

```bash
potpie skills list             [--agent ...] [--scope global|project] [--path .]
potpie skills install [<id>]   [--agent claude|codex|cursor|opencode] [--scope global|project] [--path .]
potpie skills update  [<id> | --all] [--agent ...] [--scope global|project] [--path .]
potpie skills remove  [<id> | --all] [--agent ...] [--scope global|project] [--path .]
potpie skills status           [--agent ...] [--scope global|project] [--path .]
potpie skills add     <source>            # TODO stub
```

Skills are CLI-managed instruction bundles that teach the harness how to drive the
workbench. There is **no top-level `potpie install`** — skills install via
`potpie skills install`. Scope flips to `project` automatically when `--path` is
given with `global`. Agents only ever see an advisory install nudge in
`context_status`. The full catalog, per-harness install paths, the correctness gate,
and the (separate) server-side reconciliation skill surface are documented in
[skills.md](./skills.md).

---

## Graph workbench (`commands/graph.py`) — the core surface

The `potpie graph …` workbench is **shipped today as V1.5**. Three Typer apps are defined here and
mounted at root: `graph` (with nested `inbox`, `quality`, `bulk`), plus top-level
`timeline` and `backend`. Each graph command runs inside `_graph_command(name)`,
wrapping `contract()` with the richer **workbench envelope**
(`graph_success/error/not_implemented_envelope` carrying `request_id`,
`subgraph_versions`, `warnings`, `unsupported`) and emitting OTLP spans + metrics +
usage events.

`doctor` is local-profile diagnostics: daemon/backend readiness, CLI install
facts (uv tool env, PATH, python shebang), and recommended follow-up commands.
Do not use `python -m pip show potpie-context-engine` for local dev installs —
the package lives in the uv tool environment. Prefer `uv tool list`,
`which -a potpie`, `make cli-status`, and `make cli-install` for repo-local
reinstalls (UI build + daemon stop + editable install).

```bash
uv tool list
which -a potpie
head -n 1 "$(command -v potpie)"
make cli-status
make cli-install   # repo-local reinstall only
potpie doctor
potpie --json doctor
```

### Read / contract (route `host.graph`)

```bash
potpie graph status [--pot <ref>]

potpie graph catalog [--task <text>] [--subgraph <s>] [--profile full|read] [--format auto|table|json] [--pot <ref>]

potpie graph describe [<subgraph>] [--view <v>] [--examples] [--pot <ref>]

potpie graph read --subgraph <s> --view <v> \
  [--query <text>] [--query-threshold <0..1>] [--scope <k:v,...>] [--repo <r>] \
  [--since <t>] [--until <t>] [--time-window/--window <dur>] \
  [--environment <env>] [--source-ref <ref> ...] \
  [--depth <n>] [--direction out|in|both] [--limit 12] \
  [--sort auto|score|occurred_at] [--dedupe auto|none|source_ref|activity] \
  [--format auto|raw|events|table|jsonl|json] [--detail compact|full] [--relations summary|full] \
  [--current] [--pot <ref>]

potpie timeline recent \
  [--query <text>] [--query-threshold 0.70] [--since <t>] [--until <t>] [--time-window/--window <dur>] \
  [--service <svc>] [--limit 12] [--format ...] [--detail ...] [--relations ...] [--pot <ref>]

potpie graph search-entities [<query> | --query <text>] \
  [--type <label>] [--predicate <p>] [--subgraph <s>] [--scope <k:v>] [--truth <class>] \
  [--source-system <sys>] [--source-family <fam>] [--since <t>] [--until <t>] \
  [--environment <env>] [--external-id <id>] [--source-ref <ref> ...] \
  [--limit 10] [--supporting-claims 0] [--pot <ref>]
```

- **`graph catalog`** returns the live contract (versions, commands, 7 truth classes,
  the 10 mutation ops — all `APPLICABLE`, 6 source authorities, the 10 views, the
  23 public entity types and 27 public predicates). **`--task <text>`** reorders views by
  task relevance (`ranked_catalog_views`) and adds `task_ranking` metadata to the
  output (including `--profile read`); `--subgraph` filters, `--profile full|read`
  and `--format auto|table|json` shape output. See [ontology.md](./ontology.md) for the
  catalog itself.
- **`graph describe`** routes through `GraphService.describe` like every other
  workbench command, so the ontology it reports is the serving host's build (the
  daemon's, in the default host mode), not the CLI binary's. It is a
  context-free typed metadata operation: no selected pot, engine construction,
  or Resource Manager lease is required.
- **`graph read`** is the **Retrieve** axis — resolves a named `<subgraph>.<view>`
  (one of the 10 views), validates required scope/filters, then routes through the one
  read trunk to an `AgentEnvelope` of ranked evidence. There is **no server-side
  answer synthesis**. `timeline recent` is the same path as
  `graph read --subgraph recent_changes --view timeline`. Reader/ranking/view detail
  lives in [querying.md](./querying.md).

  **Text-mode presentation** (default human output, no `--json`): `--format`,
  `--detail`, and `--relations` shape the layout and depth independently.

  | Flag | Text effect |
  |------|-------------|
  | `--format events` (timeline default) | Bullet list of deduped timeline events |
  | `--format table` | Markdown pipe table (`occurred_at \| source_ref \| …`) |
  | `--format raw` (non-timeline default) | Bullet list of ranked items |
  | `--detail compact` | Core fields only (fact, score, summary) |
  | `--detail full` | Adds truth, coverage, claim/breakdown metadata |
  | `--relations summary` | Inline relation counts and predicate names |
  | `--relations full` | Indented relation sub-lines (or secondary table with `--format table`) |

  ```bash
  # Human markdown table for recent changes
  potpie graph read --subgraph recent_changes --view timeline --format table --limit 10

  # Deeper relation detail in text mode
  potpie graph read --subgraph recent_changes --view timeline \
    --format events --detail full --relations full --limit 5

  # Non-timeline entity table
  potpie graph read --subgraph decisions --view preferences_for_scope \
    --scope language:python --format table --limit 5
  ```

  `--json` uses the same item shaping (`detail` / `relations`) but emits structured
  JSON instead of human tables/bullets. `--format json` is an accepted spelling of
  the same request.

  **Adjusted reads.** A read that can run with a bounded or canonical version of
  what was asked for runs *once* and says so, instead of refusing and costing a
  retry. The contract is shared by every read command (`potpie_context_engine.core.adjustments`):

  | Request | Effective behaviour | Disclosed as |
  |---|---|---|
  | `--depth 100` on `service_neighborhood` (max 4) | depth-4 context, same anchor and direction | `depth requested=100 effective=4 reason=maximum_supported` |
  | `--subgraph Decisions --view Preferences_For_Scope`, `--view debugging.prior_occurrences`, `--subgraph knowledge --view docs` | the canonical view, one execution | `reason=canonical_case` / `canonical_alias` |
  | `--detail summary` (read), `--detail compact` (neighborhood), `--format json` | the equivalent supported mode | `canonical_alias` / `machine_json` |
  | `--since <t> --time-window 1h` | the explicit `--since` (documented precedence) | `time_window … reason=explicit_since` |
  | `--time-window 7days` | `7d` | `reason=unit_alias`, with the effective UTC start |
  | `search-entities --type repository --predicate policy-applies-to` | `Repository` / `POLICY_APPLIES_TO` | `reason=canonical_case` |

  JSON carries `status: "adjusted"` plus an `adjustments` list (`field`,
  `requested`, `effective`, `reason`, `message`, optional `max_supported`) on the
  envelope; envelopes that adjusted nothing are unchanged. Text prints one `~ …`
  line per adjustment. Nothing is ever *guessed*: a near-miss view is refused
  with the known views listed, an unknown `--time-window` unit
  (`--time-window 2fortnights`) is refused before any host call, a conflicting
  qualified view
  (`--subgraph decisions --view debugging.prior_occurrences`) names both targets,
  reversed `--since/--until` bounds are refused rather than swapped, and
  `--depth`/`--limit` below 1 are not reads. Unknown `--format`/`--detail`
  values also fail before the host is asked, never downgraded to prose.

  Every follow-up command a read hands back — the `fetch:` line on a passage hit,
  the compact catalog's `next_read`, `describe --examples` commands — carries the
  resolved `--pot`, is `shlex`-quoted, and marks any input it could not invent as a
  `'<placeholder>'` (`next_read_is_template: true`) rather than looking runnable.
- **`graph search-entities`** is the **Filter** axis (identity resolution before a
  write) — structured per-entity lookup, **not** through the read trunk. `--type`,
  `--predicate` and `--subgraph` are checked against the serving host's advertised
  vocabulary (local registry first, the catalog only for a value it does not know)
  and refused as `unsupported_filter` with candidates when unknown
  (`--type Repositry`), so a filter that could never match is never reported as a
  confident empty result.

#### Useful reads and partial results

`features.feature_context` supports a bounded overview with no selector. It reads
only the selected pot; `--repo current` narrows the request explicitly. The
`effective_request` names the pot, scope, filters and limit that ran.

Coverage distinguishes page fullness (legacy `status` and explicit `page_status`),
measured relevance (`best_relevance`), and exhaustive coverage (`completeness`). A
bounded backend pool has unknown completeness unless exhaustion is established.
Known ranking and entity-projection cuts are disclosed separately; a claim
candidate count is not a distinct-feature count. Increasing a limit can add
context, but is not a cursor or a promise to continue an earlier page.

A view's `extra.query_threshold` declares its metric and runtime requirements. An
explicit semantic threshold requires a query. Preferences require a vector
backend; passages require a calibrated similarity index. Other views do one
bounded read with the unsupported threshold removed and return `ok: false`,
`status: partial`, an empty requested `items` answer, and a separately labelled
`fallback_context`. Scope, pot and other supported filters remain unchanged. The
CLI exits nonzero while retaining that supplemental evidence in text/JSON.
Thresholds are not probabilities.

Debugging windows mean **bug occurrence time**. Claim validity, observation time,
and fix time are separate clocks; current records do not reliably establish
occurrence time. `prior_occurrences` therefore discloses unapplied bounds and
separates any unwindowed symptom/fix context. `recent_changes.timeline` filters
activity event time; it is a different question, not a substitute occurrence
query.

Identity search reports exact matches, possible matches and misses in both
formats. An empty neighborhood distinguishes a missing key from a stored isolated
entity, and an explicit filter can report `no_matching_relations`. A missing key
is never replaced by a fuzzy candidate.

### Write (route `host.graph_workbench`)

The **canonical write door is `graph propose` → `graph commit --verify`** (Spine A;
`application/services/graph_workbench.py`). `graph mutate` is a **legacy wrapper**
that internally calls propose+commit.

```bash
potpie graph propose [--file <path> | (stdin)] [--ttl 1h] [--approved-by <who>] [--pot <ref>]
potpie graph commit  <plan_id> [--approved-by <who>] [--verify] [--pot <ref>]

potpie graph mutate  [--file <path> | (stdin)] [--dry-run] [--allow-review-required] [--approved-by <who>] [--pot <ref>]

potpie graph bulk apply [--file <path> | (stdin: NDJSON/JSON)] \
  [--chunk-size 100] [--start-chunk 1] [--dry-run] [--continue-on-error] \
  [--verify] [--manifest <path>] [--idempotency-key <k>] [--ttl 1h] [--approved-by <who>] [--pot <ref>]

potpie graph mutation-template \
  [--kind repo-baseline|feature|preference|preference-policy|infra-snapshot|bug-fix|decision|timeline-event|timeline-change]

potpie graph history [--entity <key>] [--claim <key>] [--subgraph <s>] [--plan <id>] [--mutation <id>] \
  [--since <t>] [--until <t>] [--limit 50] [--pot <ref>]

potpie graph nudge --event <e> --session <id> [--path <p>] [--scope <k:v>] [--query <text>] [--limit 5] [--pot <ref>]
```

- **`graph propose`** validates + lowers a semantic-DSL batch and persists a plan
  record (**no graph write**). The batch payload uses **flat** op fields
  (`op/subject/predicate/object/value/truth/confidence/evidence[]/description/…`;
  `append_event` uses `verb/occurred_at/actor/targets[]/mentions[]`); nested
  `{"event":{…}}`/`{"claim":{…}}` shapes will not parse. The DSL, validation/risk, and
  the diff shape are owned by [writing.md](./writing.md).
- **`graph commit <plan_id>`** applies a stored plan by id; the agent does **not**
  resend mutations. `--verify` reads the committed claims back; it exits 1 when
  a claim or its content does not read back or verification did not complete,
  and reports a quality regression alone as a warning. Medium/high-risk plans
  need `--approved-by`, either on the commit or on `propose`, which stores the
  approval with the plan.
- **`graph mutate`** — legacy wrapper (emits a warning steering to propose/commit).
  `--dry-run` previews; `--allow-review-required` + `--approved-by` auto-applies
  medium/high-risk ops.
- **`graph bulk apply`** — chunked NDJSON/JSON application with resumability
  (`--start-chunk`, `--manifest`), `--continue-on-error`, and idempotency.
- **`graph mutation-template`** — emits a static schema-only skeleton (no host call).
- **`graph nudge`** — the zero-token in-session trigger (`host.nudge.nudge`) a
  harness calls from its own lifecycle hooks; the trigger model and the event
  mapping are in [ingestion-nudge.md](./ingestion-nudge.md).
  `NudgeEvent` values: `session_start, pre_edit, pre_deploy, test_failed, test_passed, stop`.

### Inbox (`graph inbox …`) — capture uncertain work

Inbox items are pending graph work that never become facts until a harness processes
them through propose/commit.

```bash
potpie graph inbox add
potpie graph inbox list
potpie graph inbox show          <id>
potpie graph inbox claim         <id>
potpie graph inbox mark-applied  <id> [--plan <id>] [--mutation <id>]
potpie graph inbox mark-rejected <id> [--reason <text>]
potpie graph inbox close         <id>
```

`mark-applied` requires a linked `--plan` or `--mutation`. States:
pending → claimed → applied/rejected/closed.

### Quality (`graph quality …`) — read-only diagnostics

```bash
potpie graph quality summary             [--pot <ref>]
potpie graph quality duplicate-candidates [--subgraph <s>] [--limit 50] [--pot <ref>]
potpie graph quality stale-facts         [--subgraph <s>] [--limit 50] [--pot <ref>]
potpie graph quality conflicting-claims  [--subgraph <s>] [--limit 50] [--pot <ref>]
potpie graph quality orphan-entities     [--subgraph <s>] [--limit 50] [--pot <ref>]
potpie graph quality low-confidence      [--threshold 0.5] [--subgraph <s>] [--limit 50] [--pot <ref>]
potpie graph quality projection-drift    [--subgraph <s>] [--limit 50] [--pot <ref>]
potpie graph quality entity-label-drift  [--subgraph <s>] [--limit 50] [--pot <ref>]
```

Quality never writes — it recommends repairs through propose/commit or the inbox
([writing.md](./writing.md)).

### Commit history and rollback (`commands/graph_commits.py`)

```bash
potpie graph journal-status [--pot <ref>]
potpie graph commits        [--cursor <c>] [--limit 50] [--actor <a>] [--origin <o>] [--entity <key>] [--pot <ref>]
potpie graph commit-show    <commit_id> [--offset 0] [--limit 100] [--pot <ref>]
potpie graph revert         <commit_id> --expected-head <head> --preview [--pot <ref>]
potpie graph rollback       --to <commit_id> --expected-head <head> --preview [--pot <ref>]
potpie graph apply-preview  <preview_id> [--yes/-y] [--pot <ref>]
potpie graph disable-rollback [--pot <ref>]    # admin
potpie graph rebuild-commits  [--pot <ref>]    # admin
```

Each command is one typed operation over the pot's native graph journal.

- **Reads.** `journal-status` reports journal capability and coverage;
  `commits` lists recorded commits with keyset pagination (`--cursor` takes the
  previous page's `next_cursor`; `--limit` 1–200); `commit-show` returns one
  commit's recorded changes with partial historical context.
- **Preview, then apply.** `revert` (one commit) and `rollback` (every commit
  after `--to`) only build a server-held preview, and refuse to run without
  `--preview`; `--expected-head` names the HEAD you expect (`coverage.head` in
  the `commits` output). Nothing in the graph
  changes until `apply-preview` names that preview. The preview reports the
  affected records, the access it requires and its expiry, and
  `recommended_next_action` carries the exact `apply-preview` command.
- **`apply-preview` is the only destructive command here.** It needs `--yes`
  (or an interactive confirmation), and the server re-checks permission, HEAD,
  the resource generation and the inverse before writing. The graph explorer
  shows history, recorded diffs and previews, but applying stays on the CLI.
- Locally, commits are attributed to actor `local:owner`, and the runtime
  authorizes these operations only for the selected pot.
- No `potpie` command turns journal capture on yet, so on a local install
  `commits` returns no headers and reports `coverage.legacy_only: true`.
  `graph history` (plans and mutation receipts) is separate and unaffected.

### Backend-capability commands (route `host.backend`)

```bash
potpie graph neighborhood --entity <key> [--predicate <p>] [--depth 2] [--direction out|in|both] \
                          [--limit 50] [--detail summary|full] [--unbounded] [--pot <ref>]
potpie graph inspect <entity_key> [--depth 2] [--pot <ref>]      # legacy alias of neighborhood

potpie graph export <file|folder> [--overwrite] [--graph-only] [--pot <ref>]
potpie graph import <file|folder> [--graph-only] [--yes/-y] [--pot <ref>]
potpie graph repair [--semantic-index] [--entity-summaries] [--entity-labels] [--all] [--yes/-y] [--pot <ref>]
potpie graph repair --legacy --dry-run [--repair-id <id>] [--pot <ref>]
potpie graph repair --legacy --apply --repair-id <id> [--yes/-y] [--pot <ref>]
```

- **Legacy repair** reserves a frozen timestamp/physical-ID repair for review.
  Dry-run requires read access and displays bounded changes/findings; global
  `--json` includes the entire run. Apply requires admin access, confirmation and
  the reviewed ID. Retry that ID to recover completed child receipts. Expiry or a
  revision conflict requires fresh preparation for remaining work. Before
  migrating, stop all old writers, and do not rely on a schema marker to fence them.
- **`graph neighborhood`** is the **Traverse** axis (first-class), backed by
  `backend.inspection.neighborhood`. `graph inspect` is a legacy alias that warns
  toward `neighborhood`. The normal JSON slice has a 32 KiB byte budget with
  omitted node, relation and field counts and an exact `--unbounded` follow-up.
- Unbuilt capabilities surface as the structured not-implemented contract from
  the backend that executes the operation. The CLI does not preflight snapshot
  support against its own local backend profile. Per-profile coverage is in
  [architecture.md](./architecture.md).
- **`graph export`/`graph import`** move a pot's entities, claims and, by
  default, document text as a readable folder (`manifest.json`, `entities.json`,
  `claims.json`, `resources/`, `README.md`) or as one `.json` file. The CLI
  writes and reads the files; the engine exchanges the snapshot as data through
  version 2 of `export_snapshot`/`import_snapshot` and never opens a caller's
  path (version 1, the default when a request names no version, still takes
  engine-side paths; a request mixing the two is refused). Export stages next to
  the destination and refuses an existing one without `--overwrite`.
  - Import validates the whole snapshot first and needs `--yes` outside a
    terminal. It merges into the target pot, skips identical records and fails
    on conflicting content instead of overwriting, so use a new pot for a clean
    restore. Pot-scoped claim keys are rewritten for the target; document
    revision numbers are kept, so `@rev<N>` citations still resolve. Import is
    refused while commit-history capture is active on the target.
  - `--graph-only` leaves document text out on either side. Version-1 JSON
    snapshots still import, without the properties and text they never held.
  - A snapshot excludes credentials, source registrations, plans, inbox items,
    commit history and config. Document text is capped at 64 MiB and the whole
    snapshot is held in memory. Graph and documents don't share a transaction,
    so pause writers for a consistent backup.

Snapshots (`graph export`/`import`) are supported on `in_memory`, `embedded`,
`falkordb_lite`, `falkordb`, and `neo4j`. `graph inspect`/`neighborhood` remains
unavailable on `neo4j`.

---

## Backend group (`backend` → `host.backend`)

```bash
potpie backend list                 # KNOWN_PROFILES + active marker
potpie backend status               # capability report
potpie backend use <profile>        # advisory only — NOT persisted
potpie backend doctor               # backend.mutation.readiness()
```

`KNOWN_PROFILES` = `in_memory, embedded, neo4j, falkordb, falkordb_lite, postgres,
chroma, hosted`. **`backend use` is advisory only** (`persisted: False`) — it
suggests setting `CONTEXT_ENGINE_BACKEND`; it does not switch the live backend.
CLI code never queries SQLite/Neo4j/vector indexes/state tables directly — it routes
through services and capability ports.

---

## Environment switches (consolidated)

| Variable | Purpose / default |
|---|---|
| `CONTEXT_ENGINE_BACKEND` | preferred backend selector (host default `falkordb_lite`) |
| `GRAPH_DB_BACKEND` | legacy fallback selector (ingestion server default `neo4j`) |
| `CONTEXT_ENGINE_HOST_MODE` | `daemon` (default) \| `in_process` |
| `CONTEXT_ENGINE_EMBEDDER` | `none` disables the bundled local embedder |
| `CONTEXT_ENGINE_RESOURCE_INDEX` | resource retrieval index profile: `sqlite_hybrid` (default) \| `sqlite_fts` \| `none`; overrides the `resource_index` config key |
| `CONTEXT_ENGINE_ONTOLOGY_SOFT_FAIL` | downgrade-instead-of-fail validation |
| `CONTEXT_ENGINE_AGENT_PLANNER_ENABLED` | service-side LLM reconciliation (**default off**) |
| `CONTEXT_ENGINE_MAX_CHUNK_EVENTS` | batch chunk size (default 20) |
| `CONTEXT_ENGINE_RECONCILIATION_ENABLED` / `_INFER_LABELS` / `_CONFLICT_DETECT` / `_AUTO_SUPERSEDE` | reconciliation feature flags |
| `CONTEXT_ENGINE_ALLOW_UNSIGNED_WEBHOOKS`, `GITHUB_WEBHOOK_SECRET`, `CONTEXT_ENGINE_INGEST_422` | webhook/ingest controls |

The protocol ontology has no environment switch; it is the `graph.protocols`
config key (see `config` under *Top-level commands*).

Backend precedence: `CONTEXT_ENGINE_BACKEND` > `GRAPH_DB_BACKEND` >
`falkordb_lite`. There is **no `NotImplementedError` gate** on falkordb anywhere.

---

## Canonical journey

```mermaid
flowchart LR
  cf_setup["setup --repo . --agent claude"]
  cf_status["status"]
  cf_read["graph catalog → graph read / search-entities"]
  cf_write["graph propose → graph commit --verify"]
  cf_nudge["graph nudge (zero-token, harness-invoked)"]

  cf_setup --> cf_status --> cf_read --> cf_write
  cf_nudge -.-> cf_read
  cf_write -.-> cf_read
```

Local first run (OSS default — `falkordb_lite`, detached daemon, skills installed
during setup):

**Published package:**

```bash
uv tool install potpie   # or: pip install potpie
potpie setup --repo . --agent claude
potpie status
```

The base package is the complete local product. Add the `embeddings` extra
(`uv tool install 'potpie[embeddings]'`) for sentence-transformers semantic
search; without it, setup uses the bundled hashing embedder.

**This repo (local development):** prefer `make cli-install` so the graph-explorer
UI is built and any old daemon is stopped before the editable install.

```bash
make cli-install
potpie setup --repo . --agent claude
potpie status

# read the contract, then the graph
potpie graph catalog --profile read
potpie graph read --subgraph debugging --view prior_occurrences --scope service:refunds-api

# resolve identity, then write through the canonical door
potpie graph search-entities "refund timeout" --type BugPattern
potpie graph propose --file mutation.json
potpie graph commit <plan_id> --verify
```

The managed-host journey uses the same commands against a hosted graph:

```bash
printf '%s\n' "$KEY" | potpie host set https://your-host.example --token -
potpie host use managed
potpie pot list --managed
potpie pot use managed:<id> --also-default-for-current-repo
potpie resolve "what should I know before working in this repository?"
potpie graph propose --file mutation.json
```

## Output contract

- Human output: an action-oriented summary plus a suggested next command.
- `--json`: stable fields for agents/scripts (additive changes are OK); errors carry
  `code`, `message`, `detail`, `recommended_next_action`. A read that ran with a
  disclosed change to the request adds `status: "adjusted"` and `adjustments`
  (see *Adjusted reads* above); text shows the same facts as `~ …` lines.
- `setup --dry-run`: returns a preview document; no mutation, dependency setup,
  source registration, or skill install occurs.
- Destructive commands require explicit confirmation. `pot reset`,
  `pot archive` and `resource rm` use `--confirm`; graph import, repair and
  `apply-preview` use `--yes`. Without the flag, an
  interactive TTY may prompt, while JSON or non-TTY execution fails before
  reading stdin or dispatching the operation.
- Exit codes follow the `contract()` table above (`0/1/2/3/4`). A group may report a
  narrower `code` than `validation_error` where the domain has stable ones (see
  `resource`); the exit code is unchanged.

## See also

- [Context Graph](./index.md) — front door and the Start Here index.
- [vision.md](./vision.md) — what the Context Graph is and the product boundaries.
- [architecture.md](./architecture.md) — composition roots, daemon model, GraphBackend ports & coverage.
- [ontology.md](./ontology.md) — the catalogs the `catalog`/`describe` commands return.
- [querying.md](./querying.md) — the read trunk, views, ranking, and compatibility commands.
- [writing.md](./writing.md) — the semantic DSL, propose→commit, risk/validation, inbox, quality.
- [ingestion-nudge.md](./ingestion-nudge.md) — event stores, connectors, and the nudge trigger model.
- [skills.md](./skills.md) — the skill catalog, install/drift, and the harness loop.
- [observability.md](./observability.md) — span names, logs, metrics, readiness.

---

### Context Graph Ingestion Nudge (`docs/context-graph/ingestion-nudge.md`)

---
title: Ingestion and nudge
description: How source events enter the graph and how session nudges fire.
---

## Overview

This doc covers how source events get *into* the Context Graph, and how the
zero-token **nudge** model gets the *right slice back out* to an in-session agent
at the right moment. Both subsystems have drifted hard from their original
designs, so read the orientation first.

The two things this doc most wants you to internalise:

1. **There are two completely different "ledgers."** One is real and live (an
   internal Postgres event store); the other is a mostly-stubbed external seam
   (the *Event Ledger* product). The old docs conflate them.
2. **Scanners and most connectors are gone.** Repo/PR/ticket ingestion is now
   either harness-led (skills authoring semantic mutations) or a server-side
   event flow — not an in-process scanner library. The canonical write door for
   everything that lands as a *fact* is still `graph propose` → `graph commit
   --verify` (see [writing.md](./writing.md)).

---

## 1. Two "ledgers" — do not conflate them

| | Internal event store (REAL) | External Event Ledger (SEAM) |
|---|---|---|
| What it is | Postgres tables where every inbound episode/event/record lands and gets a lifecycle | A separate managed/self-hosted service that owns provider creds, webhooks, and normalized source history |
| Code | `adapters/outbound/postgres/{reconciliation_ledger,ingestion_event_store,batch_repository,ledger}.py` | `domain/ports/ledger/{client,cursor}.py` + `adapters/outbound/ledger/{managed,self_hosted}_client.py` |
| Lifecycle vocabulary | **`queued / processing / done / error`** | n/a (clients are stubs) |
| "Cursor" concept | none — it is a work queue, not a pull source | per-`(pot, source)` cursor that `fetch` advances |
| State today | live, used by the HTTP ingestion server | **mostly TODO stubs** — `ledger pull/query/status` is non-functional against any real provider (see §6) |

The lifecycle vocabulary correction matters: the live store uses
`queued/processing/done/error`, **not** the old docs' six-state
`pending/processing/applied/failed_retryable/failed_terminal/timed_out`. That
six-state list described the *external* ledger consumer, which does not exist
yet. The **"cursor" concept belongs only to the external Event Ledger** — the
internal Postgres store has no cursor.

---

## 2. How raw episodes/events enter

Ingestion is a property of the **second composition root** — the HTTP ingestion
server (`bootstrap/ingestion_server.py`, default backend `neo4j`), which is
distinct from the root-owned local runtime composition (default
`falkordb_lite`). The local OSS CLI/daemon path does **not** run this server;
local repo baseline is harness-led via skills. See
[architecture.md](./architecture.md) for the two-root split.

All inbound surfaces normalize to a single `IngestionSubmissionRequest`
(`domain/ingestion_event_models.py`) and call
`DefaultIngestionSubmissionService.submit()`
(`application/services/ingestion_submission_service.py`).

```mermaid
flowchart TD
  ing_ep1["POST /api/v1/context/ingest<br/>(raw_episode)"]
  ing_ep2["POST /api/v1/context/events/reconcile<br/>(agent_reconciliation)"]
  ing_ep3["POST /api/v1/context/record<br/>(context_record)"]
  ing_wh["POST /webhooks/github<br/>(merged PRs)"]

  ing_submit["DefaultIngestionSubmissionService.submit()<br/>_do_submit"]
  ing_record["GraphService.record()<br/>(deterministic, semantic-mutation path)"]
  ing_admit["event_admission.admit_event()"]
  ing_store["internal Postgres event store<br/>queued → processing → done/error"]

  ing_ep1 --> ing_submit
  ing_ep2 --> ing_submit
  ing_ep3 --> ing_submit
  ing_wh --> ing_submit

  ing_submit -->|"event_type == context_record"| ing_record
  ing_submit -->|"all other events"| ing_admit
  ing_admit --> ing_store
```

### Entry points

| Surface | Kind / route | Notes |
|---|---|---|
| `POST /api/v1/context/ingest` | `raw_episode` (`submit_raw_episode.py`) | The old synchronous direct-write path is **gone** — raw episodes always go through the async DB pipeline (`202`/`queued`, or `200`/`applied` with `sync=true`). Requires `DATABASE_URL`. |
| `POST /api/v1/context/events/reconcile` | `agent_reconciliation` | The canonical event-submission path; deduped by scope + `source_system` + `source_id`. |
| `POST /api/v1/context/record` | `context_record` (`record_durable_context.py`) | **The deterministic exception** — see §3. |
| `POST /webhooks/github` | `agent_reconciliation` | `GitHubConnector.normalize_webhook()` verifies HMAC (fail-closed unless `CONTEXT_ENGINE_ALLOW_UNSIGNED_WEBHOOKS=1`), emits a `ContextEvent` for merged PRs, maps repo→pot, stamps a server-trusted `webhook` Actor. |

For non-record events, `_do_submit` resolves pot/repo/source_id and calls
`event_admission.admit_event(...)`: persist the event (idempotent on
`scope + source_id` → `duplicate`), upsert it into the pot's open batch, and
either enqueue immediately or leave it for the flusher (windowed mode, §5). It
also persists the ingress W3C `traceparent` onto the row's `correlation_id` so
the later fan-in span can link back (see [observability.md](./observability.md)).

---

## 3. The deterministic `context_record` exception

`event_type == "context_record"` is detected inside `_do_submit` and routed to
`GraphService.record(...)` — the **semantic-mutation path**, *not* the
reconciliation agent. This is the one ingestion entry that produces a fact
deterministically, with no LLM in the loop. P6 structured payloads
(`domain/context_records.py`: `fix` / `bug_pattern` / `preference` / `decision` /
`verification`) are validated here and embedded into
`payload["record"]["structured"]`.

`record` is the write behind the local CLI `potpie record`. It is **Spine B** — the immediate
`DefaultGraphService.mutate` path via the record→semantic bridge. Full mechanics
live in [writing.md](./writing.md); the point here is only that
`context_record` short-circuits the async reconciliation pipeline entirely.

---

## 4. Scanners gone; connectors trimmed to github + notion

This is the biggest ingestion correction versus the old docs.

- **Scanners are DELETED.** `adapters/outbound/scanners/` holds only stale
  `__pycache__` (codeowners / openapi / kubernetes / dependency-manifest /
  default_registry) — no `.py` source remains (removed in commit `5af8ea5f`,
  "mega-removal of dead CE surface"). Repo-baseline ingestion is now
  **harness-led**: the `potpie-repo-baseline` and `potpie-source-ingestion`
  skills author semantic mutations through `graph propose` → `graph commit --verify`
  (see [skills.md](./skills.md)).
- **Only `github` + `notion` remain as outbound `SourceConnectorPort`
  connectors** (`adapters/outbound/connectors/{github,notion}/` + `_bench_stubs.py`).
  `connectors/jira/` and `connectors/linear/` are stale `.pyc` only.
  Connectors implement `kind / capabilities / list_artifacts / normalize_webhook
  / fetch`; `propose_plan` was removed, so **connectors no longer write claims**.
- **jira/linear reads are harness-led, not CLI queue-driven.** The old
  pot-level CLI queue-ingest commands have been removed. Hosted-source
  hydration should happen through the harness and integration tools, while
  server-side event ingestion continues to flow through the HTTP endpoints
  above. The actual jira/linear *reads* live in
  `adapters/outbound/cli_auth/{atlassian_read_client,linear_read_client}.py`.

---

## 5. Windowed batching + reconciliation

> **Roadmap (not yet wired) for OSS:** service-side LLM reconciliation is
> **parked / non-canonical** and **off by default**.
> `domain/reconciliation_flags.py agent_planner_enabled()` defaults to `False`
> (opt-in via `CONTEXT_ENGINE_AGENT_PLANNER_ENABLED=1`, or per-batch via
> playbooks). The `factory.py` docstring's "default: on" is wrong. Canonical
> writes come from harness-authored semantic mutations + the deterministic
> `context_record` path — not from this agent. (Other reconciliation flags
> *do* default on: infer-labels, conflict-detect, auto-supersede, causal-expand,
> strict-extraction, ontology-soft-fail.)

When events do flow through the async pipeline, the shape is:

1. **Batching** — `admit_event` coalesces events into a pot's single open
   `pending` batch (`domain/reconciliation_batch.py` states:
   `pending/claimed/running/done/failed`). Per-pot config (`immediate` vs
   `windowed`, `window_minutes` default **5**, `min_batch_size`) is set at
   `PUT /pots/{id}/ingestion-config`.
2. **Flush** — `flush_windowed_batches.flush_ready_windowed_pots` (periodic)
   enqueues every windowed pot whose batch aged past the window or exceeded min
   size; `force_flush_pot` backs `POST /pots/{id}/ingest/flush`.
3. **Worker** — `context_graph_jobs.handle_process_batch` rebuilds the
   container, claims the batch, opens the fan-in **`batch.process`** span linked
   to each event's ingress trace, and calls `process_batch.process_batch`.
4. **process_batch** authorizes `apply.write` **once** (a single policy
   boundary), splits events into chunks of `CONTEXT_ENGINE_MAX_CHUNK_EVENTS`
   (default **20**), runs `ReconciliationAgentPort.run_batch` per chunk with a
   fresh pydantic-ai message history (cross-chunk state lives in the graph, not
   the prompt), and is crash-resumable via the durable agent execution log. It
   marks events `reconciled`/`failed`, opens a per-event **`reconciliation_run`**
   ledger row (`context_reconciliation_runs`), and streams liveness as NDJSON.
5. **The agent** (`adapters/outbound/reconciliation/{factory,pydantic_deep_agent,
   noop_agent,context_graph_tools}.py`) writes `:RELATES_TO` edges through the
   one backend write door (`apply_plan`, [writing.md](./writing.md)) — using a
   **separate** server-side skill surface (`backfill-enumerate-drain`,
   `graph-mutation-plan`) that must never be conflated with the user-installed
   bundle skills ([skills.md](./skills.md)).

The spans on this path are the fan-in **`batch.process`** and the per-chunk
**`agent.run_batch`** (the per-event `reconciliation_run` is a durable ledger
row, not a span); all are catalogued in [observability.md](./observability.md).

---

## 6. The external Event Ledger + cursors

> **Roadmap (not yet wired):** the external Event Ledger clients are TODO stubs.
> `adapters/outbound/ledger/managed_client.py` and `self_hosted_client.py`:
> `fetch` returns an empty `LedgerPage`, `query` raises
> `CapabilityNotImplemented`, `health` reports `available=False`. So
> `potpie ledger pull/query/status` exists as a command surface but is
> **non-functional against any real provider today**.

The **design** (ports `domain/ports/ledger/client.py EventLedgerClientPort` +
`domain/ports/ledger/cursor.py LedgerCursorStorePort`):

- A managed/self-hosted service owns provider credentials, webhooks, and
  normalized source history.
- The local daemon only *pulls* normalized `LedgerEvent`s and advances a
  per-`(pot, source)` cursor; it never owns provider creds.
- A harness turns pulled events into semantic mutations — the ledger is **not**
  the graph source of truth.

`EventLedgerClientPort` surface: `fetch` (cursor-advancing pull), `query`
(read-only history that never moves the cursor), `sources`, `health`. Only
`FixtureEventLedgerClient` (in-process, tests) does real paging today, and
`LocalLedgerCursorStore` persists cursors to `~/.potpie/ledger_cursors.json`
keyed `pot:source`. CLI flags for `ledger pull/query/use/disconnect` are listed
in [cli-flow.md](./cli-flow.md).

---

## 7. The zero-token "nudge" trigger model

Nudges are how durable graph memory reaches an in-session agent **without
spending tokens to ask for it** — at lifecycle moments a harness calls
`potpie graph nudge`, which reads the graph, and the harness injects the
relevant slice (or a directive) into the agent's context. The whole trigger
brain is **deterministic — no model on this path**; similarity uses the bundled
local embedder only.

Three model-free layers:

### 7.1 Policy (`domain/nudge.py`)

`NudgeEvent` is a 6-value enum, each mapped to exactly one `NudgePolicy` by
`NUDGE_POLICIES` (an import-time `_check_nudge_policies_coherent()` enforces
one-policy-per-event). A policy's `direction` is either **data** (read named
graph views and inject evidence) or **instruction** (return a fixed write-prompt
for the agent to act on — never an auto-write):

| Event | Direction | What it does |
|---|---|---|
| `session_start` | data | read scoped baseline views |
| `pre_edit` | data | read views relevant to the file/scope being edited |
| `pre_deploy` | data | read deploy-relevant views |
| `test_failed` | data | reads `debugging.prior_occurrences` + `recent_changes.timeline` |
| `test_passed` | instruction | prompt the agent to record a `REPRODUCES`/`RESOLVED` claim |
| `stop` | instruction | prompt the agent to record durable learnings |

Data policies name `NudgeViewSpec`s (which `domain.graph_views` view to read);
the views themselves are owned by [querying.md](./querying.md).

> `NudgePolicy.triggers_ingest` is **dead** — `NudgeService` never reads it,
> nothing on the nudge path runs ingest, and the top-level `potpie ingest`
> command was removed in `5af8ea5f`. A nudge never ingests; it only reads or instructs.

### 7.2 Executor (`application/services/nudge_service.py NudgeService`)

Composes the single read trunk (`GraphReadPort.read` over a named view,
similarity via the **local embedder only**) plus a per-session
`InjectionLedgerPort`:

- **Data events** — read each view, globally rank by score, drop
  already-injected/duplicated keys, budget to top-K, and return a compact
  source-ref-first block (`description` > `summary` > `fact`, plus scope bits and
  `src=`).
- **Instruction events** — return the directive string verbatim.
- Returns `silent=True` when nothing is fresh/relevant.

`NudgeService` is wired by `potpie/runtime/composition.py` with the graph service
and `LocalInjectionLedger`, then exposed through the context-bound typed
operation catalog.

### 7.3 Dedup ledger (`adapters/outbound/session/injection_ledger.py`)

`LocalInjectionLedger` persists injected keys per session to
`~/.potpie/nudge_sessions.json` (atomic write, bounded to 500 sessions) so dedup
**survives across the short-lived `graph nudge` processes within one
session**.
`InMemoryInjectionLedger` is the test double.

### 7.4 The CLI surface

```
potpie graph nudge --event <e> --session <id> [--path --query --scope --pot --limit]
```

routes to `host.nudge.nudge(GraphNudgeRequest)`. Full flags are in
[cli-flow.md](./cli-flow.md).

### 7.5 Wiring a harness

Potpie ships no hook adapter: a harness invokes nudges itself. A harness with
lifecycle hooks runs `potpie --json graph nudge` at the moments the policy
covers, passing its own session id as `--session` (the dedup key):

- session start → `session_start`
- before a file edit → `pre_edit`, with `--path <file>`
- before a deploy command → `pre_deploy`
- after a test run → `test_failed` (with the failure text as `--query`) or
  `test_passed`
- end of the agent's turn → `stop`
- anything ambiguous → no call

It reads the `result` object of the envelope: inject `inject_context` or
`instruction` into the agent's context, and nothing when `silent` is true. Keep
the hook fail-safe — on a missing binary, a non-zero exit, or output it cannot
parse, inject nothing rather than break the user's session.

The agent's half of the loop (how to act on an injected slice vs a directive) is
taught in the `potpie-graph` skill's "Responding To Nudges" — see
[skills.md](./skills.md).

---

## See also

- [writing.md](./writing.md) — the `record`→semantic bridge, the semantic DSL,
  and the canonical `propose` → `commit --verify` write door.
- [skills.md](./skills.md) — harness-led repo/source ingestion skills, the
  separate server-side reconciliation skill surface, and "Responding To Nudges."
- [architecture.md](./architecture.md) — the two composition roots / two HTTP
  roots that this doc's ingestion server vs local spine split rests on.
- [querying.md](./querying.md) — the named views a nudge reads.
- [cli-flow.md](./cli-flow.md) — full flags for `ledger ...` and `graph nudge`.
- [observability.md](./observability.md) — `batch.process` / `agent.run_batch`
  spans (and the `reconciliation_run` ledger row) and ingestion metrics.

---

### Context Graph Observability (`docs/context-graph/observability.md`)

---
title: Observability
description: "Logs, traces, metrics, and the backend-neutral observability port."
---

## Overview

Observability is a thin, backend-neutral **port** (`domain/ports/observability.py
ObservabilityPort`) that the domain and application layers emit spans and metrics
through. They never import `opentelemetry` directly — that import lives only inside
the OTel adapter, which is what keeps the backend swappable (OTLP → Tempo/Prometheus
today, anything else tomorrow). The default sink is `NoOpObservability`: every call
site is safe to invoke unconditionally, and a local CLI/daemon stays completely dark
unless an operator opts in. Observability never raises into a caller — it can never
fail a request.

Both composition roots wire the same port: the root-owned local runtime
(`potpie/runtime/composition.py`) and the standalone ingestion HTTP server
(`bootstrap/ingestion_server.py`) — see [architecture.md](./architecture.md).

## Shape

```mermaid
flowchart LR
  obs_core["shared services<br/>Pot Mgmt · Graph Service<br/>Graph Workbench · Skill Manager"]
  obs_ingest["ingestion pipeline<br/>(HTTP server · batch worker)"]
  obs_port["ObservabilityPort<br/>(domain seam)"]
  obs_noop["NoOpObservability<br/>(default — dark)"]
  obs_console["ConsoleObservability"]
  obs_otel["OtelObservability → OTLP"]

  obs_core --> obs_port
  obs_ingest --> obs_port
  obs_port --> obs_noop
  obs_port --> obs_console
  obs_port --> obs_otel
```

Default behavior:

- Local OSS is **dark by default** (`NoOpObservability`); no spans, metrics, or remote
  telemetry leave the process unless explicitly configured.
- Logs always work (stdlib logging), but structured/JSON output and trace correlation
  are opt-in.
- Core code emits through the observability port, not vendor SDKs. The OTel adapter
  (`adapters/outbound/observability/otel.py`) is the *only* `opentelemetry` import.

## Configuration

```bash
# Sink selection (unset / 0 / false / off => NoOp, the default)
CONTEXT_ENGINE_OBSERVABILITY=console     # off | console | <any-truthy => OTLP>

# OTLP export only activates when an endpoint is present (else falls back to NoOp)
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=...   # alternative to the line above
OTEL_SERVICE_NAME=context-engine         # default: context-engine

# Logging (independent of the trace/metric sink)
CONTEXT_ENGINE_LOG_FORMAT=json           # plain (default) | json
CONTEXT_ENGINE_LOG_LEVEL=INFO            # default: INFO
```

`bootstrap/observability_wiring.py default_observability()` resolves the sink:
`console` builds `ConsoleObservability` (dependency-free trace skeleton); any other
truthy value builds `OtelObservability` **only if** an OTLP endpoint is set, otherwise
it stays NoOp. A missing OTel extra or any setup failure also degrades silently to
NoOp. OTLP export therefore lives behind optional dependencies and explicit config.

## The port

`ObservabilityPort` (`domain/ports/observability.py`) exposes:

- `span(name, *, kind, attributes, links)` — context manager yielding a `Span`
  (`set_attribute(s)` / `add_event` / `record_exception` / `set_error`). `kind` is a
  small string set mapped to the OTel enum inside the adapter: `internal` (default),
  `server`, `client`, `producer`, `consumer`. `links` is a sequence of W3C
  `traceparent` strings — how the batch trace links back to each event's ingress trace.
- `current_traceparent()` — W3C traceparent of the active span (persisted into
  `context_events.correlation_id` at admission so the async batch can correlate across
  the windowed delay).
- `baggage(**items)` — attaches OTel baggage around the reconciliation agent run so the
  child spans pydantic-ai emits inherit pot/batch/run/event ids.
- `counter` / `histogram` / `gauge` — metric primitives.

### Correlation spine

`bootstrap/observability_context.py` carries a fixed, documented correlation key set
in a single `contextvars` var: **`trace_id, pot_id, event_id, batch_id, run_id, seq,
chunk, source`**. Anything outside this set is dropped so log/trace cardinality stays
bounded. Keys are bound at ingress and re-bound inside the batch worker, and injected
into every log record (see Logging) so any line or span ties back to its event.

## Trace Map

The `Code boundary` column names the module each span instruments, so spans attach to
the same seams the Code Map in [architecture.md](./architecture.md) defines. These are
the spans actually emitted today.

| Span | Kind | Meaning | Code boundary |
|---|---|---|---|
| `graph.<command>` | internal | One workbench command: `graph.status`, `graph.catalog`, `graph.describe`, `graph.search_entities`, `graph.read`, `graph.neighborhood`, **`graph.propose`**, **`graph.commit`**, `graph.bulk`, `graph.history`, `graph.inbox` (carries `operation`), `graph.quality` (carries `report`), plus the legacy `graph.mutate` / `graph.mutation_template` / `graph.nudge`. | `potpie/cli/commands/graph.py` (`_graph_command`) |
| `ingest.submit` | server | Inbound episode/event/record normalized and submitted. | `application/services/ingestion_submission_service.py` |
| `HTTP <method> <route>`, `http.ready` | server | Ingestion HTTP server request + readiness. | `adapters/inbound/http/_hardening.py`, `adapters/inbound/http/api/router.py` |
| `batch.process` | consumer | Windowed batch fan-in (N events → 1 run → M mutations); **links back** to each event's ingress traceparent. | `application/use_cases/context_graph_jobs.py` |
| `agent.run_batch` | internal | One reconciliation-agent chunk run. Emitted only when the agent planner is enabled (off by default). | `application/use_cases/process_batch.py` |
| `<prefix>.<op>` | client | Public methods of an instrumented infra adapter (e.g. `neo4j.<op>`), wrapped at the composition root. | `bootstrap/observability_proxy.py` (`instrument_adapter`) |

`graph.propose` and `graph.commit` are the canonical write-door spans (propose →
commit `--verify`); `graph.mutate` is the legacy wrapper that internally proposes then
commits. See [writing.md](./writing.md).

Notes:

- **Reads do not emit per-reader spans.** The `subgraph`/`view`/`match_mode` ride as
  attributes on `graph.read`; the read trunk (`ReadOrchestrator` → 10 readers →
  `EnvelopeBuilder`) is described in [querying.md](./querying.md).
- The per-event **`reconciliation_run`** is a durable ledger row
  (`context_reconciliation_runs`), *not* a span. It is linked to `batch.process` by
  `run_id` and surfaced through `graph status` / the explorer UI.
- Batch ingestion uses **span links**, not a single synchronous parent, so delayed
  fan-in is represented honestly rather than as one long request. See
  [ingestion-nudge.md](./ingestion-nudge.md).

> **Roadmap (not yet wired):** external Event Ledger spans (`ledger.pull`,
> `ledger.query`, `ledger.cursor.update`). The managed/self-hosted ledger clients are
> TODO stubs, so these paths are non-functional against real providers today (see
> [ingestion-nudge.md](./ingestion-nudge.md)). Do not confuse the external Event Ledger
> with the live internal Postgres event store, which *is* instrumented above.

## Metrics

All metric names are `ce.*`. Graph-workbench and batch metrics are mirrored to both the
`ObservabilityPort` and a Sentry metrics runtime.

Workbench commands (`potpie/cli/commands/graph.py`) — one counter +
histogram per command root:

- `ce.graph.<root>_total` and `ce.graph.<root>_ms` for each root (`read`, `propose`,
  `commit`, `catalog`, `status`, `describe`, `search_entities`, `neighborhood`,
  `history`, `inbox`, `quality`, `bulk`, `mutate`, …). Bounded label set:
  `{result, error_code, pot_id, subgraph, view, risk, status, operation, report,
  backend_profile, match_mode}`.

CLI envelope:

- `ce.cli.invocations_total`, `ce.cli.duration_ms`.

Ingestion pipeline (the live internal event store + batch worker):

- `ce.ingest.events_total`, `ce.ingest.dedup_total` (admission);
- `ce.batch.started_total`, `ce.batch.finished_total`, `ce.batch.reaped_total`;
- `ce.batch.time_in_pending_ms` — the windowed-5min canary: if the flusher wedges,
  this screams first;
- `ce.events.reconciled_total`, `ce.events.failed_total`.

Reconciliation agent + cost telemetry (only when the planner is enabled):

- `ce.agent.tool_calls`, `ce.agent.timeout_total`;
- `ce.llm.calls_total`, `ce.llm.input_tokens_total`, `ce.llm.output_tokens_total`,
  `ce.llm.tokens_total`, `ce.llm.latency_ms`.

Instrumented infra adapters (`instrument_adapter`):

- `ce.<prefix>.query_ms{op}`, `ce.<prefix>.errors_total{op}` (e.g. `ce.neo4j.*`).

Setup + quality/drift:

- `ce.setup.runs_total`, `ce.setup.step_total`;
- gauges `ce.drift.missing_coverage`, `ce.drift.open_conflicts`,
  `ce.drift.source_access_gaps`, `ce.drift.stale_refs`,
  `ce.drift.verification_failed_refs`.

Readiness:

- gauge `ce.dependency_up{dependency}`.

> **Roadmap (not yet wired):** external Event Ledger metric families (`ce.ledger.*`,
> `ce.event_ledger.*`) are not emitted — the ledger clients are stubs.

## Logging

Logging is configured in one place (`bootstrap/logging_setup.py configure_logging()`,
idempotent, stdlib-only). A `CorrelationFilter` attaches the active correlation keys
(above) to every record, so any log line can be tied back to its trace / event / pot /
batch / run. Two formats:

- **`plain`** (default) — human format that still surfaces the trace:
  `… [trace=… event=… pot=…] message`.
- **`json`** — one JSON object per line: `ts/level/logger/msg` + the present
  correlation keys + any structured `extra=` fields (including the `audit` channel,
  which was previously dropped on the floor).

Every request, daemon action, or pipeline step should therefore carry: the active
`trace_id`/`request id`, `pot_id`, the `event/batch/run` ids when in the async pipeline,
and the service-boundary logger name (`daemon`, `pot_management`, `graph_service`,
`graph_backend`, `skill_manager`, `ingestion`, …). Backend name is included where safe.

Local logs are discoverable from:

```bash
potpie daemon logs
potpie doctor
```

> **Roadmap (not yet wired):** managed-profile log fields (`origin`/`profile`
> `managed`, managed backend URL host, `ledger binding` `managed`/`self_hosted`) apply
> only once managed routing and the external ledger ship — both currently raise
> `CapabilityNotImplemented` / are stubs.

## Readiness

Liveness and readiness are separate: a daemon can be **live** while graph storage or
semantic search is **not ready**. Readiness is composed, not flattened — `potpie
doctor` assembles `backend.capabilities()` + `backend.mutation.readiness()`
(`BackendReadiness`) + `daemon.status()` + `ledger.status()`, and `potpie graph status`
returns `data_plane_status`. Deeper `doctor` diagnostics should preserve these owner
boundaries rather than collapsing every dependency into one status bit. (Full command
flags in [cli-flow.md](./cli-flow.md).)

Local readiness checks:

- daemon process and version;
- active pot;
- registered sources;
- Graph Service, plus the active `GraphBackend` name and its capability set (which of
  the six ports are real vs fail-closed — see [architecture.md](./architecture.md));
- semantic index and embedder (bundled local embedder by default);
- Skill Manager catalog and installed-vs-recommended skills.

> **Roadmap (not yet wired):** managed-backend readiness (hosted Pot Management / Graph
> Service / Skill Manager, auth/policy, hosted graph/search profile, queue/worker
> dependencies, cloud skill-sync) and external Event Ledger readiness (ledger API,
> store, connector/webhook health, per-source cursor/lag, third-party provider auth).
> `ledger.status()` exists, but its managed/self-hosted clients are stubs, so the
> ledger surface reports unavailable rather than real provider state.

## See also

- [architecture.md](./architecture.md) — composition roots, the Code Map, the
  `GraphBackend` port + capability coverage table.
- [ingestion-nudge.md](./ingestion-nudge.md) — the internal Postgres event store vs the
  external Event Ledger seam; windowed batching and the batch worker.
- [cli-flow.md](./cli-flow.md) — `doctor`, `daemon logs`, `graph status` command flags.
- [writing.md](./writing.md) — the propose → commit write door behind `graph.propose` /
  `graph.commit`.

---

### Context Graph Ontology (`docs/context-graph/ontology.md`)

---
title: Ontology and graph contract
description: "Entity types, predicates, truth classes, and named graph views."
---

## Overview

This document is the canonical reference for Potpie's **ontology and graph
contract** — the static, spec-driven layer that the shipped `potpie graph
catalog` / `describe` / `status` commands return, sitting above the V1.5 data
plane. It replaces the older `workbench-ontology.md`, whose entity and relation
catalogs were aspirational; the catalogs below are enumerated from code.

The contract is **declarative**. It is built from three catalogs plus a
contract-constants module; everything else (identity registry, singleton
registry, classifier tables, ranker inputs, the agent-facing surface) is a
**view** over those catalogs, and import-time coherence checks fail startup loud
if the views and catalogs ever drift apart. Nothing here is "future Graph V2":
the `potpie graph …` workbench is shipped today as **V1.5**. The string `"v2"`
survives only as the workbench *envelope* version (§9).

For how reads execute over this contract see [`querying.md`](./querying.md); for
how writes are validated, lowered, and committed see [`writing.md`](./writing.md);
for the full command surface see [`cli-flow.md`](./cli-flow.md).

## Where the contract lives

| Concern | Module |
|---|---|
| The three catalogs (entities / predicates / record types) + derived views | `domain/ontology.py` |
| Contract constants (versions, truth classes, ops, source authorities, evidence rules) | `domain/graph_contract.py` |
| Identity minting, prefix rules, the environment qualifier | `domain/identity.py` (+ `graph_contract.py`) |
| Singleton predicate registry | `domain/singleton_predicates.py` |
| Executable workbench contract (subgraphs / views, describe, rank) | `domain/graph_workbench_ontology.py` |
| Named view specs | `domain/graph_views.py` |
| Workbench command envelope + command sets | `domain/graph_workbench.py` |
| Import-time ontology coherence guards | `domain/coherence.py` |

```mermaid
flowchart TB
  ont_e["ENTITY_TYPES<br/>(25 labels)"]
  ont_p["EDGE_TYPES<br/>(27 public + RELATED_TO)"]
  ont_r["RECORD_TYPES"]
  ont_c["graph_contract.py<br/>versions / truth / ops / authorities"]

  ont_views["Derived views<br/>identity registry · singleton registry={OWNED_BY, SECTION_OF}<br/>classifier tables · fact-family policy"]
  ont_wb["Workbench contract<br/>8 subgraphs · 10 views"]
  ont_out["graph catalog / describe / status"]
  ont_guard{{"import-time coherence guards<br/>(coherence.py)"}}

  ont_e --> ont_views
  ont_p --> ont_views
  ont_e --> ont_wb
  ont_p --> ont_wb
  ont_r --> ont_views
  ont_c --> ont_wb
  ont_views --> ont_out
  ont_wb --> ont_out
  ont_guard -. validates .-> ont_views
  ont_guard -. validates .-> ont_wb
```

Module rule (from `domain/ontology.py`): **add an entity = one row in
`ENTITY_TYPES`; add a predicate = one row in `EDGE_TYPES`; add a record type =
one row in `RECORD_TYPES`.** The registries and the workbench contract recompute
from those rows at import.

## 1. The three catalogs

### 1.1 `ENTITY_TYPES` — 25 labels

Each `EntityTypeSpec` row carries category, description, identity
(`identity_class`, `key_prefix`, `identity_policy`, `authoritative_source`),
structural traits (`scope`, `is_activity`), agent-family mappings,
source-of-truth/freshness (`fact_family`, `source_of_truth`,
`freshness_ttl_hours`), and classifier cues (`text_patterns`,
`property_signatures`). Design pillar: **an entity exists only if an edge needs
it as an endpoint.**

| Category | Labels | Notes |
|---|---|---|
| Topology scope endpoints (`scope=True`) | `Repository`, `Service`, `Environment`, `DataStore`, `Cluster`, `DeploymentTarget` | match the `@Scope` endpoint sentinel |
| Code-anchored topology | `Dependency`, `APIContract`, `Adapter`, `ConfigVariable`, `CodeAsset` | `CodeAsset` also matches legacy `FILE`/`FUNCTION`/`CLASS`/`NODE` labels |
| Product | `Feature` | |
| People | `Team`, `Person` | |
| Timeline | `Activity` (`is_activity=True`), `Period` | |
| Memory tier | `Preference`, `Policy`, `BugPattern`, `Fix`, `Decision` | |
| Reference material | `Document`, `DocumentSection` | structure only — chunk payloads live in the resource store, see [`architecture.md`](./architecture.md) |
| Generic fail-open fallbacks (`public=False`) | `Observation`, `QualityIssue` | not advertised to agents; downgrade targets |

**`Activity` is the single timeline collapse point.** PRs, commits, issues,
incidents, and deployments do **not** get distinct entity types — they all mint
as one `Activity` entity (key prefix `activity`). There is no `PullRequest`,
`Commit`, `Issue`, `Incident`, `Deployment`, `Component`, `SourceReference`,
`Capability`, `Runbook`, or `Investigation` label; any doc or skill that names
those is stale.

### 1.2 `EDGE_TYPES` — 27 public predicates + `RELATED_TO`

Each `EdgeTypeSpec` declares `allowed_pairs`, `category`,
`required_properties`, `singleton`, `predicate_family`, `exclusive_family`, and
`source/target_inferred_labels`. Endpoint sentinels resolved in
`_endpoint_matches`: `WILDCARD_ENDPOINT="*"`, `SCOPE_ENDPOINT="@Scope"` (any
`scope=True` label), `ACTIVITY_ENDPOINT="@Activity"`, plus `CodeAsset` for the
legacy code-graph labels.

| Category | Count | Predicates |
|---|---:|---|
| topology | 11 | `DEFINED_IN`, `DEPLOYED_TO`, `DEPENDS_ON`, `USES`, `USES_ADAPTER`, `CONFIGURES`, `DEPLOYED_WITH`, `EXPOSES`, `HOSTED_ON`, `PROVIDES`, `IMPLEMENTED_IN` |
| ownership | 1 | `OWNED_BY` — `singleton=True` |
| people | 1 | `MEMBER_OF` |
| timeline | 5 | `TOUCHED`, `PERFORMED`, `AUTHORED`, `IN_PERIOD`, `MENTIONS` |
| memory | 7 | `POLICY_APPLIES_TO`, `REPRODUCES`, `RESOLVED`, `ATTEMPTED_FIX_FAILED`, `VERIFIED`, `DECIDED`, `AFFECTS` |
| knowledge | 2 | `SECTION_OF` — `singleton=True`; `DOCUMENTS` |
| generic | 1 | `RELATED_TO` (`public=False` catch-all) |

That is 27 agent-facing predicates plus the `RELATED_TO` fallback = **28 keys**
total. `RELATED_TO` is the universal downgrade target and rides the same
canonical `:RELATES_TO` storage edge as every other predicate (the storage edge
is a single relationship type; the predicate name lives in its `name` property —
see [`architecture.md`](./architecture.md)).

**`SUPERSEDES` is a system edge** (`SYSTEM_EDGE_TYPES`): emitted by the writer
during supersession, deliberately kept out of agent-facing `EDGE_TYPES`.
`ALL_EDGE_TYPES = CANONICAL_EDGE_TYPES | SYSTEM_EDGE_TYPES`.

**The timeline is read-time, not stored.** There are deliberately **no**
`TRIGGERED_BY` / `PRECEDED_BY` / `HOTSPOT` edges; ordering, windowing, and
correlation are queries over `valid_at` (= `occurred_at`), not stored temporal
edges. Predicate names like `FIXES`, `CAUSED`, `IMPLEMENTS`, `CALLS`,
`AFFECTED_BY`, `MATCHES_PATTERN`, or `HAS_ROOT_CAUSE` that appear in older docs
**do not exist** — use the memory predicates above (`REPRODUCES`, `RESOLVED`,
`ATTEMPTED_FIX_FAILED`, `VERIFIED`) for the bug/fix lifecycle.

### 1.3 `RECORD_TYPES`

`RecordTypeSpec` joins the agent `context_record` / `potpie record` surface to
the ontology: `record_type → anchor_label → emits_predicate → payload_schema →
reader_include`.

| `record_type` | anchor label | emits predicate | reader include |
|---|---|---|---|
| `preference`, `policy` | `Preference` / `Policy` | `POLICY_APPLIES_TO` | `coding_preferences` |
| `bug_pattern` | `BugPattern` | `REPRODUCES` | `prior_bugs` |
| `fix` | `Fix` | `RESOLVED` | `prior_bugs` |
| `verification` | (fix) | `VERIFIED` | `prior_bugs` |
| `decision` | `Decision` | `DECIDED` (+ `AFFECTS`) | `decisions` |
| *(any other)* | `Document` / `Observation` | — | — (free-form, no schema/reader) |

`Document` is also the anchor for ingested reference material, which
`potpie resource import` mints directly rather than through a free-form
record — see the resource store in [`architecture.md`](./architecture.md).

`STRUCTURAL_INCLUDES = {features, infra_topology, timeline, owners, raw_graph}`
are reader-backed includes that have **no** record-type anchor — they are
populated by structural mutations, not by `record`. The structured-to-semantic
mapping (which exact op each record type lowers to) is owned by
[`writing.md`](./writing.md).

## 2. Derived views over the catalogs

At import, `ontology.py` recomputes a set of read-only registries so nothing has
a second hand-maintained list:

- `CANONICAL_LABELS`, `SCOPE_LABELS`, `ACTIVITY_LABELS`, `SINGLETON_EDGE_TYPES`.
- **Identity registry** (`domain/identity.py _sync_identity_registry()`) — a lazy
  view over `ENTITY_TYPES`, not a standalone "Identity Resolver" module.
- **Singleton registry** (`domain/singleton_predicates.py`) — synced to
  `replace_singletons(SINGLETON_EDGE_TYPES)`; the live set is **exactly
  `{OWNED_BY, SECTION_OF}`** — one live owner per service, one parent document
  per section. `_DEFAULT_SINGLETONS` lists legacy names but they are
  overwritten at import.
- **Classifier tables** — `ENTITY_TEXT_CLASSIFIERS`,
  `ENTITY_PROPERTY_SIGNATURES`, `EDGE_ENDPOINT_INFERRED_LABELS`, consumed by
  `domain/ontology_classifier.py` (deterministic, idempotent label inference;
  returns nothing when ambiguous).
- **Fact-family policy** — `FACT_FAMILY_FRESHNESS_TTL_HOURS`,
  `SOURCE_OF_TRUTH_POLICIES`.
- **Predicate families** — `PREDICATE_FAMILY_EDGE_NAMES`,
  `EXCLUSIVE_PREDICATE_FAMILY_EDGE_NAMES` (used for conflict detection and
  supersession).

## 3. Contract constants (`domain/graph_contract.py`)

This module is the single contract home.

### 3.1 Versions

| Constant | Value |
|---|---|
| `GRAPH_CONTRACT_VERSION` | `"v1.5"` |
| `ONTOLOGY_VERSION` | `"2026-06-graph"` |
| `SUPPORTED_GRAPH_CONTRACT_VERSIONS` | `{"v1.5"}` |
| workbench envelope `graph_contract_version` | `"v2"` (mirrors the same `ontology_version`) |

The ontology version is `2026-06-graph` — **not** `2026-06-graph-v2`. The `"v2"`
in the workbench envelope is purely the envelope's contract-version string; the
data plane is v1.5.

### 3.2 Truth classes — 7 (per claim)

`TruthClass` is a **per-claim** assertion (distinct from the per-entity
`source_of_truth`). It feeds the ranker (§5).

| Truth class | Use |
|---|---|
| `authoritative_fact` | direct source-of-truth field from an authoritative source |
| `source_observation` | observed output/event, not necessarily durable truth |
| `agent_claim` | **default**; inference grounded in evidence |
| `user_decision` | explicit decision from user/team/record |
| `preference` | durable user/team/project preference |
| `timeline_event` | append-only historical activity |
| `quality_finding` | system-generated graph-quality diagnosis |

### 3.3 Mutation ops — 10, all `APPLICABLE`

`SemanticMutationOp` has exactly ten members:

`upsert_entity`, `link_entities`, `assert_claim`, `append_event`,
`end_relation_validity`, `retract_claim`, `supersede_claim`,
`merge_duplicate_entities`, `patch_entity`, `transition_state`.

**`APPLICABLE_MUTATION_OPS` = all 10. `REVIEW_REQUIRED_OPS = ()` and
`DEFERRED_OPS = ()` are both empty tuples.** Every op is advertised as directly
applicable; review/blocking is **not** an op partition — it is decided at runtime
by a `MutationRisk` (`low` / `medium` / `high`) per operation (owned by
[`writing.md`](./writing.md)). So `merge_duplicate_entities`, `supersede_claim`,
and `retract_claim` are **not** "always review-required"; they auto-apply with
`--allow-review-required --approved-by`, else return `review_required` by risk.

There is no `reconcile_snapshot` op — it survives only as a stale comment in
`domain/reconciliation.py`. Any op outside the ten above is fictional.

### 3.4 Source authorities — 6

`SourceAuthority`: `repository_metadata`, `authoritative_code`,
`external_system`, `ci_run`, `user_statement`, `agent_observation`.
`STRONG_AUTHORITIES` = all except `agent_observation`. (The older
`product_tracker` / `infrastructure_inventory` / `observability_signal` /
`documentation` / `conversation` / `agent_inference` / `manual_user_input` list,
and the per-field `{field, authoritative, allowed_claims, review_required}` JSON,
are not in code; `describe` returns flat `{authority, strength, description}`.)

## 4. Evidence model

Two coupled vocabularies map truth class onto rank strength and onto an
evidence-gating rule.

**Truth class → evidence strength** (`TRUTH_TO_EVIDENCE_STRENGTH`):

| Truth class | Evidence strength |
|---|---|
| `authoritative_fact`, `source_observation` | `deterministic` |
| `user_decision`, `preference`, `timeline_event` | `attested` |
| `agent_claim` | `stated` |
| `quality_finding` | `inferred` |

`evidence_strength_for_truth()` defaults to `stated`.

**Evidence gating:**
- `EVIDENCE_REQUIRED_TRUTH_CLASSES = {authoritative_fact, source_observation}` —
  objective facts must cite a strong authority.
- `LOW_AUTHORITY_TRUTH_CLASSES = {agent_claim, quality_finding}` — explicitly
  soft; need no evidence.

A missing agent-authored `description` is a **warning, never a reject** (recall
depends on the description being written as a retrieval card — see §5 and
[`querying.md`](./querying.md)).

> **Caveat — two evidence-strength vocabularies exist in code.**
> `ontology.EVIDENCE_STRENGTHS = (deterministic, attested, inferred,
> hypothesized)` with `DEFAULT_EVIDENCE_STRENGTH="inferred"`, whereas the ranker
> (`domain/ranking.py _STRENGTH_TO_SCORE`) uses `(deterministic, attested,
> stated, inferred, speculative)`. The truth-class map above targets the
> **ranker** vocabulary; `ontology.EVIDENCE_STRENGTHS` is the older set (has
> `hypothesized`, lacks `stated`/`speculative`) and is not what the ranker
> consumes.

## 5. Truth class feeds the ranker

A claim's truth tier drives its rank weight without the ranker learning a second
vocabulary: `strength` in the ranker comes from `_STRENGTH_TO_SCORE`
(`deterministic` 1.0 → `speculative` 0.2), which is exactly the vocabulary
`TRUTH_TO_EVIDENCE_STRENGTH` maps onto. The ranker combines six factors as a
weighted **arithmetic** mean (`semantic_similarity` 1.3, `strength` 1.2,
`scope_overlap` 1.1, `recency` 1.0, `corroboration` 0.8, `coverage_quality`
0.5), each kept in a per-factor `breakdown`. Full ranking detail lives in
[`querying.md`](./querying.md). The single read-result shape carries only an
envelope-level `overall_confidence` (a coverage rollup) — there is **no**
per-claim "confidence" trust score on reads.

## 6. Identity keys, minting & the environment qualifier

**The LLM proposes a *name*; Potpie computes the *key*** (`identity.py
mint_entity_key`). There are three identity classes:

| Identity class | Key derivation |
|---|---|
| `SLUG_ALIAS` | `_slugify(name)` — lowercase, hyphenate, underscores → hyphens |
| `EXTERNAL_ID` | `_normalize_external_id` — preserves `. _ / -` |
| `CONTENT_HASH` | 12-hex sha256 of canonical content |

**Canonical prefix rule** (`graph_contract.py` +
`IdentityPolicy.canonical_prefix_rule`): prefixes are **exact** and
**underscore-canonical** (`bug_pattern:`, `api_contract:`). Hyphenated prefixes
are **not** accepted as aliases; hyphens stay valid in the *body*
(`service:payments-api`). Notable specifics:

- `Document`'s prefix is **`document`** (not `doc`); `DocumentSection`'s is
  **`docsection`** (`docsection:<doc>:<section>`).
- `Document` is `SLUG_ALIAS` → `document:<slug>`. It was `CONTENT_HASH` before
  the resource store; the prefix did not change, so old `document:<hash>` nodes
  stay valid but will not converge with a re-import.
- `Decision` is `CONTENT_HASH` → `decision:<hash>`.
- `Activity` (PRs/commits/issues/incidents/deployments) mints as
  `activity:<source>:<id>`.
- There is no `component:` or `source-ref:` key family (those entities do not
  exist).

The exact `key_prefix` for every type is declared on its `ENTITY_TYPES` row —
treat that as the source of truth rather than memorizing a per-type table.
Cross-source slug convergence is handled by `ALIAS_OF` claims (an alias is itself
a canonical edge); merges create merge-record edges and never hard-delete.

**Environment qualifier** (`edge_identity_key`): an edge's identity is normally
`(subject, predicate, object)`; when an `environment` qualifier is present it
becomes `(subject, predicate, object, environment)`. Singleton and supersession
keys derive from this tuple, so an env-qualified edge **never supersedes its
counterpart in another environment**. `make_claim_key()` similarly folds `@<env>`
into the object component. The `infra_topology.service_neighborhood` view
enforces `environment_filter` (default `qualified_only`; opt out with
`include_unqualified_environment`).

## 7. The executable workbench contract

`domain/graph_workbench_ontology.py _build_contract()` composes a
`WorkbenchOntologyContract` at import (then `assert_ontology_contract_coherent`).
It is what `graph catalog`/`describe` return.

### 7.1 Subgraphs — 8

`_SUBGRAPH_DEFINITIONS` are eight hand-authored slices:

| Subgraph | Covers |
|---|---|
| `debugging` | bug patterns, fixes, reproductions, verifications |
| `recent_changes` | the activity timeline |
| `infra_topology` | runtime topology, dependencies, environments, config |
| `decisions` | decisions, preferences, policies |
| `features` | product capabilities and their implementations |
| `code_topology` | code assets and ownership-by-path |
| `knowledge` | documents, document sections, and observations |
| `admin` | exposes the full `tuple(ENTITY_TYPES)` / `tuple(EDGE_TYPES)` |

There is **no** `project_map`, `operations`, `quality`, or `bugs` subgraph
(`bugs` is the common mistake — the prior-occurrences view lives under
**`debugging`**).

### 7.2 Views — 10

`GRAPH_VIEWS` (`domain/graph_views.py`) defines ten `<subgraph>.<view>`
contracts:

| View | Result shape |
|---|---|
| `decisions.preferences_for_scope` | entity relations |
| `decisions.active_decisions` | flat claims |
| `debugging.prior_occurrences` | entity + relations (`REPRODUCES`/`RESOLVED`/`ATTEMPTED_FIX_FAILED`/`VERIFIED`) |
| `recent_changes.timeline` | events |
| `infra_topology.service_neighborhood` | entity relations (Traverse) |
| `features.feature_context` | entity relations |
| `code_topology.ownership_by_path` | entity relations |
| `knowledge.document_context` | flat claims |
| `knowledge.document_passages` | flat claims (passages from the resource index, not the claim store) |
| `admin.inspection_slice` | raw graph |

There is no `features.implementation_map`, `changes_near_scope`, or
`constraints_for_scope` view. Each `GraphViewSpec` carries `v1_include` (the
reader family), `backed` (**derived** from `READER_BACKED_INCLUDES`, not
hand-set), `inputs`, `inline_relations`, `ranking_inputs`, and a `traversal`
flag. `_VIEW_OVERRIDES` enrich each with purpose, `required_any_scope`,
`optional_scope`, `supported_filters`, `result_shape` (∈
`flat_claims`/`entity_relations`/`events`/`raw_graph`), and keywords; backed
views auto-add `source_ref`. How a view actually executes through the read trunk
is owned by [`querying.md`](./querying.md).

### 7.3 describe & rank

- `describe_contract()` returns a subgraph (optionally a single view).
- `rank_views_for_task()` / `ranked_catalog_views()` deterministically rank views
  for a task string: view-keyword hit = 6, subgraph-keyword = 3, description = 1;
  +1 if backed; `admin` −2; ties broken by `_SUBGRAPH_TIE_BREAKER`. The ranking
  function exists, but **`graph catalog --task` is accepted and ignored in
  V1.5** — catalog returns the full ranked set.

### 7.4 Optional protocol extension (off by default)

The protocol ontology is the one optional extension, and nothing turns it on by
default. A Potpie local runtime composes it when the `graph.protocols` config
key is `on` (`potpie config set graph.protocols on`, then `potpie daemon
restart` for a running daemon). An embedding host passes
`potpie_context_engine.api.protocols_definition()` to `build_graph_runtime`
itself. That factory and its identity helpers are not yet a supported API, and
no extension-registration hook is published.

With it on, the catalog reports `extensions: {"protocols": "1"}` and adds:

| Kind | Added |
|---|---|
| Subgraph | `protocols` |
| Entity types | `Protocol` (a specification revision and profile), `ProtocolMessage` (a versioned message definition, never an observed exchange), `ProtocolField` (a full field path with sourced layout and typed values) |
| Predicates | `DEFINES_MESSAGE`, `HAS_FIELD`, `CAN_SEND`, `CAN_RECEIVE`, `RESPONDS_TO`, `PROTOCOL_IMPLEMENTED_BY` |
| View | `protocols.message_context`, include family `protocols` |

Protocol keys are SHA-256 hashes of a versioned, typed identity tuple, minted
with `potpie_context_engine.api.protocol_entity`: a revision or profile change is
a new entity, and integer `2`, string `"2"` and `false` stay distinct. Writes go
through the usual semantic plans with `subgraph: protocols`; no record type is
added.

With it off, none of that is advertised, protocol writes are refused as unknown
types and predicates, and protocol-specific validation, journal restore checks
and resource source protection do not run. Data written while it was on stays
stored, and the view returns it once the key is back on. While off, removing or
refreshing a source that protocol claims cite is not refused; when the key is
back on, those claims read as unverified, never complete.

## 8. Source-of-truth, freshness & quality cues

Each entity row declares a `fact_family`, `source_of_truth`, and
`freshness_ttl_hours`; `SOURCE_OF_TRUTH_POLICIES` and
`FACT_FAMILY_FRESHNESS_TTL_HOURS` are the derived policy tables. At read time the
quality layer derives a source-ref TTL from the fact family
(`fact_family_for_source_type`) to flag stale facts. `Document` /
`DocumentSection` use `fact_family=documents` (distinct from soft-fail
`evidence`) so a doc corpus does not inherit Observation freshness or ranking
policy. Quality findings are purely
diagnostic and never write directly — they propose `propose`/`commit` corrections
or inbox items (full quality flow in [`writing.md`](./writing.md)).

## 9. Workbench envelope & command sets

`domain/graph_workbench.py GraphCommandEnvelope` is the uniform shell every
`potpie graph …` command returns:

```json
{
  "ok": true,
  "command": "graph.read",
  "request_id": "req:01JY...",
  "pot_id": "local/default",
  "graph_contract_version": "v2",
  "ontology_version": "2026-06-graph",
  "subgraph_versions": { "_global": 137 },
  "result": {},
  "warnings": [],
  "unsupported": [],
  "recommended_next_action": null,
  "error": null
}
```

Note `ontology_version` is `2026-06-graph` and `subgraph_versions` is the
pot-wide `{"_global": <pot revision>}` token — there are **no** real
per-subgraph versions (the concurrency model is owned by
[`writing.md`](./writing.md)).

Command sets:

| Set | Commands |
|---|---|
| `GRAPH_WORKBENCH_COMMANDS` | `status`, `catalog`, `describe`, `search-entities`, `read`, `neighborhood`, `propose`, `commit`, `bulk`, `history`, `inbox`, `quality` |
| `GRAPH_WORKBENCH_ADMIN_COMMANDS` | `repair`, `export`, `import` |
| `GRAPH_WORKBENCH_LEGACY_COMMANDS` | `mutate`, `inspect`, `mutation-template`, `nudge` |

The **canonical write door is `graph propose` → `graph commit --verify`.**
`graph mutate` is a **legacy wrapper** that internally calls propose+commit and
emits a steering warning; `graph inspect` is a legacy alias of `neighborhood`.
There is no `graph admin` command and no `graph reset` (reset is `pot reset`).

## 10. Coherence guards

The contract layer fails loud rather than drifting silently. `domain/coherence.py
_run_import_time_checks()` runs at module load and asserts: identity labels ⊆
`ENTITY_TYPES`; `RECORD_TYPES` anchor labels ⊆ `ENTITY_TYPES` and emit predicates
⊆ `EDGE_TYPES`; every reader-include is advertised; `STRUCTURAL_INCLUDES` is
disjoint from record includes; every `payload_schema` has a builder.
`_check_views_coherent` asserts every view's `v1_include ∈
CONTEXT_INCLUDE_VALUES`. `assert_ontology_contract_coherent` validates every
subgraph entity/relation against the catalogs and that op-policy partitions match
`graph_contract`. Failures raise `OntologyCoherenceError` — the rule is *"align
the declaration, don't relax the check."*

This is **ontology-vocabulary** coherence (the catalogs and their views stay
aligned), not per-write transactional integrity. The runtime coherence check
(`assert_runtime_coherence`, asserting the live reader registry equals
`READER_BACKED_INCLUDES`) and the write-time validation/risk invariants are
covered in [`writing.md`](./writing.md).

## 11. Evolving the contract

The real evolution mechanism is the module rule: add a row to `ENTITY_TYPES`,
`EDGE_TYPES`, or `RECORD_TYPES`, let the derived registries and the workbench
contract recompute, and satisfy the coherence guards. Versions
(`GRAPH_CONTRACT_VERSION`, `ONTOLOGY_VERSION`) bump when the contract or ontology
changes.

> **Roadmap (not yet wired):** the elaborate ontology-evolution lifecycle from
> the old `workbench-ontology.md` — staged contract states
> (`draft`/`experimental`/`active`/`deprecated`/`retired`), per-subgraph and
> per-view version units, change-class policy, and a formal proposal template —
> is **not encoded** today. The op-partition machinery for review/deferral
> (`REVIEW_REQUIRED_OPS`/`DEFERRED_OPS`) exists in code but is intentionally
> empty; review is a runtime risk decision (§3.3).

## See also

- [`querying.md`](./querying.md) — how reads execute over this contract (read
  trunk, readers, ranking, named views, the 3-axis model).
- [`writing.md`](./writing.md) — the semantic DSL, validation/risk, lowering,
  propose→commit, and runtime coherence invariants.
- [`architecture.md`](./architecture.md) — the Position-B canonical `:RELATES_TO`
  storage model and the `GraphBackend` capability ports.
- [`cli-flow.md`](./cli-flow.md) — the full `potpie graph …` command surface and
  flags.

---

### Context Graph Querying (`docs/context-graph/querying.md`)

---
title: Querying the Context Graph
description: How agents and humans read ranked evidence from project memory.
---

## Overview

This doc owns the **read side** of the Context Graph: how an agent or human pulls
ranked, sourced evidence out of project memory. Writing is covered in
[`writing.md`](./writing.md); the static contract those reads are shaped by (entities,
predicates, subgraphs, views, truth classes) lives in [`ontology.md`](./ontology.md);
the full command/flag reference lives in [`cli-flow.md`](./cli-flow.md).

The governing principle, true on every read path: **Potpie returns ranked evidence,
never a synthesized answer.** The agent reasons over the evidence. There is no
server-side answer summary anywhere in the read trunk.

---

## 1. One CLI, one data plane

The CLI exposes two read shapes over the **same** service and canonical claim
store. Both are shipped today (the data plane is
`GRAPH_CONTRACT_VERSION="v1.5"`, `ONTOLOGY_VERSION="2026-06-graph"`).

| Altitude | Surface | Who | Commands |
|---|---|---|---|
| **Compatibility commands** | `AgentContextService` (`application/services/agent_context.py`) | humans + harnesses over the CLI | `resolve`, `search`, `record`, `status` |
| **Graph Surface Lite** | the `potpie graph …` workbench (`potpie/cli/commands/graph.py`) over `DefaultGraphService` | humans + harnesses over the CLI | `graph catalog/read/search-entities/neighborhood/describe/status/history` (+ the write/inbox/quality commands in [`writing.md`](./writing.md)) |

The richer workbench and compatibility commands are both CLI surfaces. `resolve`/`search`/
`record` on `AgentContextService` delegate straight to `GraphService`; only `status`
is composite (data-plane status + `PotManagementService` + a `SkillManager` install
nudge). Both altitudes are implemented by `DefaultGraphService`
(`application/services/graph_service.py`) over a swappable `GraphBackend`.

> The old framing of `resolve`/`search`/`record` as "Graph V1" and the workbench as a
> "future Graph V2" is wrong: both ship now. The string `"v2"` survives **only** as the
> workbench *envelope* version (`GRAPH_WORKBENCH_CONTRACT_VERSION`), not as a separate
> product. See [`architecture.md`](./architecture.md) for the composition roots.

---

## 2. The single read trunk (P8/P9)

Every read — `resolve`/`search`, `graph read`, named views, and the zero-token
nudge ([`ingestion-nudge.md`](./ingestion-nudge.md)) — collapses onto one path:

```mermaid
flowchart LR
  q_in["resolve / search / graph read / nudge"]
  q_orch["ReadOrchestrator.resolve()"]
  q_vocab["includes_for_request<br/>(agent_context_port)"]
  q_readers["10 P9 readers"]
  q_cqp[("ClaimQueryPort.find_claims")]
  q_rank["RankingService (P7)"]
  q_env["EnvelopeBuilder.build()"]
  q_out(["AgentEnvelope"])

  q_in --> q_orch
  q_orch --> q_vocab
  q_orch --> q_readers
  q_readers --> q_cqp
  q_readers --> q_rank
  q_readers --> q_env
  q_env --> q_out
```

`ReadOrchestrator.resolve()` (`application/services/read_orchestrator.py`) does four
things:

1. **normalize `intent` → include families** via `includes_for_request`
   (`domain/agent_context_port.py`);
2. **route each include to its P9 reader** through a `_routing` dict of **10 readers**;
3. **run each reader over the canonical `ClaimQueryPort`** (no reader touches storage
   directly);
4. **hand `(include, ReadResponse)` pairs to `EnvelopeBuilder.build()`** → one
   `AgentEnvelope`.

`DefaultGraphService.resolve()` and `.search()` both call the orchestrator (`search`
forces `intent="unknown"` and sets `metadata.search=True`). `mode` and `source_policy`
ride only in `metadata` — in V1.5 they do **not** change the read path, the readers, or
the ranking. They are advertised for forward-compatibility, nothing more.

### Anti-phantom-vocabulary rule

The trunk never returns a silent zero for a vocabulary mismatch:

- an advertised include with **no implemented reader** → `UnsupportedInclude(reason="not_implemented")`;
- an include **not in the vocabulary at all** → `unknown_include`.

A coherence guard requires `READER_BACKED_INCLUDES` (the advertised set) to mirror
`ReadOrchestrator.backed_includes` (the runtime registry) exactly; a mismatch fails
startup. So "I asked for X and got nothing" is always distinguishable from "X is not a
real family."

---

## 3. The AgentEnvelope

`AgentEnvelope` (`domain/agent_envelope.py`) is the **one** read-result shape:

| Field | Meaning |
|---|---|
| `items` | ranked `EvidenceItem`s: `include`, `candidate_key`, `score`, `payload`, `coverage_status`, ranker `breakdown` |
| `coverage` | per-include `CoverageReport`: `status ∈ {complete, partial, sparse, empty}` + `candidate_pool` + `graph_view` (the canonical `<subgraph>.<view>` serving that include — the forward pointer that teaches envelope callers the workbench vocabulary) |
| `unsupported_includes` | the not_implemented / unknown includes from the anti-phantom rule |
| `overall_confidence` | a **coverage rollup**, not a trust score (see below) |
| `as_of` | read timestamp |
| `metadata` | search flag, mode/source_policy echo, match_mode, etc. |

`EnvelopeBuilder` sorts items **cross-include by descending ranker score** and rolls up
coverage. `overall_confidence` (`derive_overall_confidence`) is computed off the **worst
per-include coverage tier** — `complete→high`, `partial→medium`, `sparse/empty→low`. It
is a measure of *how much of what you asked for was found*, **not** a model trust or
per-claim probability. Per-claim trust is carried on each item as the ranker `score`,
`breakdown`, `truth`, and `evidence_strength`. (When surfaced through `graph read`,
`overall_confidence` reappears as `quality.confidence`.)

### What an item carries

- **Bounded details.** Items carry authored, kind-specific details: a fix's root
  cause, remedy and check outcomes; a decision's rationale and rejected
  alternatives; `EXPOSES` API links in infra neighbourhoods. They survive compact
  and full reads, JSON and human output. Detail text is capped at 2,000
  characters and lists at 12 entries, with omission metadata and a quoted,
  pot-preserving follow-up command that opens the full entity or source text.
- **Verification outcomes.** Only explicit successful checks add corroboration;
  failed, partial, unknown and numeric statuses do not. Older `VERIFIED` claims
  with no outcome are labelled `legacy_verified`.
- **Identity lookup** distinguishes exact matches, ambiguity across repositories,
  missing identifiers and approximate candidates. Keep repo scope for a bare PR
  or issue number.
- **Time.** A claim is current from its start (inclusive) to its end (exclusive),
  so an end scheduled for next week leaves it current today; `as_of` evaluates
  the same interval at another time. An `Activity`'s `occurred_at` is when it
  happened and `observed_at` when Potpie learned of it, so linking it later does
  not move it in the timeline. New writes normalize timestamps to UTC; older
  native records with other offsets need an explicit repair.
- **Citations are revision-pinned** (`potpie://res/<doc>/<section>/<seq>@rev<N>`)
  and always open that revision's text. Claims whose evidence changed carry an
  evidence-review marker in named reads, `resolve`/`search` and
  `graph quality stale-facts`; the marker says the evidence needs review, not
  that the conclusion is false.

An empty or sparse result does not prove the source has no relevant fact; read
`coverage` before concluding.

---

## 4. Reader pattern and the 10 readers

Each P9 reader (`application/readers/`) is small and uniform. Shared scaffolding lives
in `_common.py`: `ReadRequest`/`ReadResponse`, `make_task_context`,
`coverage_status_from_count`, `rank_candidates`, `claim_payload`, `dedupe_claim_rows`,
`scoped_entity_keys`/`service_anchor_keys`, `row_in_anchor_set`, plus the `depth`/
`direction` Traverse controls. A reader: picks a predicate set → calls
`ClaimQueryPort.find_claims` (passing `fact_query` as the semantic anchor) → computes a
use-case-specific `scope_overlap` → builds `Candidate`s → delegates ranking to the
shared `RankingService`.

The 10 readers map one-to-one onto the 10 named views (§8):

| Reader (`include`) | Predicates it reads | Notes |
|---|---|---|
| `coding_preferences` | `POLICY_APPLIES_TO` | hierarchical/containment overlap over `code_scope`; a hard-zero overlap drops the row |
| `features` | `PROVIDES`, `IMPLEMENTED_IN` | anchored on service/repo, expands to feature keys (`traversal` view) |
| `infra_topology` | `DEFINED_IN`, `DEPLOYED_TO`, `DEPENDS_ON`, `USES`, `USES_ADAPTER`, `CONFIGURES`, `DEPLOYED_WITH`, `EXPOSES`, `HOSTED_ON`, `OWNED_BY` | the **in-view Traverse axis**: bounded BFS (`depth` capped at 4, `direction` out/in/both) with environment-qualified edge filtering |
| `timeline` | `MENTIONS`, `TOUCHED`, `PERFORMED`, `AUTHORED` | `Activity` rows touching scope inside a `since/until` window (`occurred_at`, fallback `valid_at`); dedupes edges per activity into one event |
| `prior_bugs` | `RESOLVED`, `ATTEMPTED_FIX_FAILED`, `VERIFIED`, `REPRODUCES` | expands the bug/fix neighborhood, folds `VERIFIED` into fix corroboration, labels failed attempts, hides narrower-scope bugs from broader queries |
| `decisions` | `DECIDED`, `AFFECTS` | active/superseded decisions for a scope |
| `owners` | `OWNED_BY`, `MEMBER_OF` | ownership by scope/path |
| `docs` | `Document\|DocumentSection DOCUMENTS scope` (plus legacy `Document RELATED_TO scope`), expanded one hop over `SECTION_OF` | scoped reference material — see the resource store in [architecture.md](./architecture.md) |
| `resources` | none — reads the resource index, not the claim store | passages from ingested documents, each with the chunk id `potpie resource get` takes — see the resource store in [architecture.md](./architecture.md) |
| `raw_graph` | every live `:RELATES_TO` edge (incl. generic `RELATED_TO`) | unscoped, for the explorer UI — **not** an agent retrieval family |

> The `service_neighborhood` view spec (`domain/graph_views.py` `inline_relations`)
> and the reader (`application/readers/infra_topology.py` `_INFRA_PREDICATES`) now
> agree: both traverse `EXPOSES`, so API links reach the neighbourhood. Feature
> links (`PROVIDES`, `IMPLEMENTED_IN`) belong to the `features` reader.

The vocabulary single source of truth is `domain/agent_context_port.py`: it owns
`CONTEXT_INTENTS` (12), `READER_BACKED_INCLUDES` (10), `CONTEXT_INCLUDE_VALUES` (derived
from the ontology's `advertised_include_families()`), `PLANNED_INCLUDES`
(advertised-but-unbacked → surfaced as `not_implemented`), `DEFAULT_INTENT_INCLUDES`, and
`CONTEXT_RESOLVE_RECIPES`. `context_port_manifest()` is the stable agent-facing
instruction blob.

---

## 5. The recall mechanism: `ClaimQueryPort` + `fact_query` → `semantic_similarity`

`ClaimQueryPort.find_claims(ClaimQueryFilter)` (`domain/ports/claim_query.py`) is the
single read seam under every reader. The filter carries the **hard axes** as a query
(pot, `predicate_in`, subject/object/claim_key/subgraph/mutation_id/source_ref/
source_system, subject/object label, validity window, `as_of`, `include_invalidated`,
`limit`) plus **one semantic anchor**, `fact_query`.

When `fact_query` is set, the backend ranks rows and stamps
`row.properties["semantic_similarity"]`, which the reader reads into
`Candidate.semantic_similarity`. The match strategy is surfaced on every read as
`match_mode` so empty results are debuggable:

- **`vector`** — with an `EmbedderPort` wired, real cosine over the persisted
  `fact_embedding` (embedding the retrieval card on the fly for rows that lack one).
  The bundled local embedder ships by default, so semantic search needs no API key.
- **`lexical`** — with no embedder, token-overlap scoring (`embedding_score` in
  `canonical_claim_query.py`).

Hard filters live in the **query**, not the ranker. `ClaimRow` carries the V1.5
first-class claim metadata as typed fields (`claim_key`, `subgraph`, `truth`,
`confidence`, `description`, `environment`, `observed_at`, `valid_until`, `mutation_id`,
`source_refs`, `evidence`, contract/ontology version, `fact_embedding`); `properties`
holds only non-contract extras.

---

## 6. Ranking (P7)

One uniform `RankingService` (`domain/ranking.py`) ranks every reader's candidates.
Score = **weighted arithmetic mean** of six factors, each clamped to `[0,1]`, a missing
factor defaulting to a neutral `0.5`:

| Factor | Weight |
|---|---|
| `semantic_similarity` | 1.3 (primary recall) |
| `strength` | 1.2 |
| `scope_overlap` | 1.1 |
| `recency` | 1.0 |
| `corroboration` | 0.8 |
| `coverage_quality` | 0.5 |

The arithmetic mean **deliberately replaced** the old weighted geometric mean, whose
floor let a single zero factor veto an otherwise strong candidate. `strength` comes from
`_STRENGTH_TO_SCORE` (deterministic 1.0 / attested 0.8 / stated 0.6 / inferred 0.45 /
speculative 0.2) — the same vocabulary a claim's **truth class** maps onto
(`TRUTH_TO_EVIDENCE_STRENGTH`, see [`ontology.md`](./ontology.md)), so the truth tier
drives rank weight without the ranker learning a second vocabulary. `recency` is
exponential decay with a 30-day half-life shifted by freshness preference;
`corroboration` is diminishing returns on source count. Every `RankedItem` keeps a
per-factor `breakdown` for explainability.

(Doc nit preserved from code: the module docstring still uses `×` shorthand even though
`_combine` is a weighted sum.)

### The retrieval card (R2)

`build_retrieval_card` (`domain/retrieval_card.py`) is the **single** builder for the
text a claim is embedded and searched as — shared by the embed-on-write path and the
read path so the two never drift. It concatenates, in order: the agent-authored
`description`, then `fact`, the humanized subject key, predicate, object key, object
value, **key-sorted** scope values, and extra terms, joined by `" • "`. Scope is sorted
so equivalent scopes embed identically. This is why skills insist the `description` is a
**retrieval card** (symptoms + synonyms + the scope a future searcher would type), not a
display string — recall quality is retrieval quality, and it lives almost entirely in
this card plus the ranker.

---

## 7. The 3-axis query model (all three shipped)

Every agent question reduces to one of three composable, typed, pot-scoped,
backend-portable axes — never the raw store or its query language. **All three are
first-class today**; Traverse is no longer "buried inside a view."

| Axis | Command | Service path | Answers |
|---|---|---|---|
| **Retrieve** | `graph read --subgraph <s> --view <v>` | `DefaultGraphService.read()` → read trunk | "What preferences apply here?" "Has this symptom been seen before?" "What changed recently?" |
| **Filter** | `graph search-entities` | `DefaultGraphService.search_entities()` → `claim_query.find_claims` **directly** | "Which adapter is wired in prod?" "List active decisions for this repo." Identity resolution before a write. |
| **Traverse** | `graph neighborhood` | `backend.inspection.neighborhood` | "What depends on `payments-api`, two hops out?" "Walk this bug to its fix and the PR that shipped it." |

**Retrieve** goes through the read trunk over a named view. **Filter** does *not* hit the
read trunk — it queries `claim_query.find_claims` with structured filters (type/predicate/
subgraph/scope/truth/source_system/source_family/environment/external_id/source_refs/
since/until), aggregates claim rows **per entity** (best `semantic_similarity` + optional
supporting claims), resolves display labels, and returns ranked `GraphEntityCandidate`s;
its stated purpose is "narrow entity/claim lookup for identity resolution before a write."
**Traverse** is `backend.inspection.neighborhood` — a bounded, depth-limited,
predicate-typed, direction-aware walk; `graph inspect <key>` is a legacy alias that warns
toward `neighborhood`. The same `depth`/`direction` controls are plumbed through
`ReadRequest` into `InfraTopologyReader`, so the `infra_topology.service_neighborhood`
view's in-view traversal and the standalone op share semantics.

> Edge qualifiers are part of identity. A relation's `environment` qualifier joins the
> edge identity key `(subject, predicate, object, environment)`, so prod and staging
> coexist rather than supersede each other; the `service_neighborhood` view enforces
> `environment_filter` (default `qualified_only`). Filter and Traverse both depend on
> this. Full identity rules: [`ontology.md`](./ontology.md).

> **Boundary:** arbitrary cross-predicate shortest path, centrality/PageRank, cycle
> detection, and unbounded recursive aggregation are deliberately **out of scope** on the
> agent surface. This is a project-memory graph for retrieval-into-context, not a graph
> analytics engine.

---

## 8. Named views & `GraphViewSpec`

The default read unit is a **named view**, not arbitrary traversal. A view is a
declarative `<subgraph>.<view>` contract (`GraphViewSpec`, `domain/graph_views.py`):
`v1_include` (which reader family backs it), `backed` (**derived** from
`READER_BACKED_INCLUDES`), `inputs`, `inline_relations`, `ranking_inputs`, and a
`traversal` flag. An import-time guard (`_check_views_coherent`) asserts every
`v1_include ∈ CONTEXT_INCLUDE_VALUES`.

There are **8 subgraphs** (`debugging`, `recent_changes`, `infra_topology`, `decisions`,
`features`, `code_topology`, `knowledge`, `admin`) and **10 views**:

| Subgraph | View | Backing reader | Use when |
|---|---|---|---|
| `decisions` | `preferences_for_scope` | `coding_preferences` | What preferences/policies apply to this scope. |
| `decisions` | `active_decisions` | `decisions` | Checking architectural/user decisions for a repo/service/feature. |
| `debugging` | `prior_occurrences` | `prior_bugs` | Debugging a recurring symptom; prior bugs, fixes, failed attempts, verifications. |
| `recent_changes` | `timeline` | `timeline` | What changed recently, and why. |
| `infra_topology` | `service_neighborhood` | `infra_topology` | Runtime service/environment/dependency context. |
| `features` | `feature_context` | `features` | Feature summary, ownership, implementation links. |
| `code_topology` | `ownership_by_path` | `owners` | Who owns a path/module. |
| `knowledge` | `document_context` | `docs` | Scoped document/runbook context. |
| `knowledge` | `document_passages` | `resources` | Which passage of an ingested document says it. |
| `admin` | `inspection_slice` | `raw_graph` | Operator/explorer raw slice. |

> Note the corrected names: the prior-occurrences view is under subgraph **`debugging`**
> (not `bugs` — `graph read --subgraph bugs …` fails with "unknown graph view",
> carrying `did_you_mean` + `recommended_next_action` migration guidance that points
> at the canonical `debugging.prior_occurrences`), and the only features view is
> **`features.feature_context`** (there is no `features.implementation_map`).

When the optional protocol ontology is on (`graph.protocols`, off by default;
see [ontology.md](./ontology.md)), an eleventh view, `protocols.message_context`,
is backed by the `protocols` reader family:

- It needs `anchor_entity_key` (a `Service`, `Protocol`, `ProtocolMessage` or
  `ProtocolField` key) and accepts only exact `revision`, `profile` and
  `field_path` filters plus `--query`, which narrows message discovery. Any
  other filter (`--environment`, `--repo`, `--depth`, a time window, a
  threshold) is refused.
- Default resolve recipes, chat hints and nudges never include it. Ask for it
  with `potpie resolve "<question>" --include protocols`, then read each
  returned message by its anchor with `--detail full`.
- Its responses stay inside the shared 32 KiB agent output budget, less a
  6 KiB reserve for the result around the items, and one message takes at most
  half of the budget. The reader drops whole trailing fields and marks the
  message `partial` and `truncated`, rather than letting the generic bound clip
  typed values. A discovery read that anchors on a `Service` or `Protocol` and
  selects several messages therefore returns full layouts for the first ones
  and compact pointers for the rest. Read each message by its own anchor, and
  narrow a large layout with `field_path`.

`graph read` resolves the named view, validates `required_any_scope` + `supported_filters`
against the view's `ViewContract` (returning `missing_required_scope`/`unsupported_filter`
**instead of** running a malformed read), then routes `spec.v1_include` through the same
orchestrator with `intent=None`. If the view declares `inline_relations`,
`_assemble_inline_relation_items` reshapes flat ranked claims into `entity + relations[]`
payloads (`read_shape="entity_relations"`). `timeline recent` and
`graph read --subgraph recent_changes --view timeline` are the **same path**.

### Example: read response (`debugging.prior_occurrences`)

```
/* Detailed source-code truncated for AI context efficiency. */
```

Note the real shape, corrected from the old docs: debugging relations are
`REPRODUCES`/`RESOLVED`/`ATTEMPTED_FIX_FAILED`/`VERIFIED` (there is no `FIXES`
predicate); normalized relations use `valid_at`/`valid_until`; PRs/commits/issues/
deployments collapse to a single `Activity` entity (key prefix `activity`);
`coverage` rows are keyed by the requested **view** (`view`, not `include` — a read
routes exactly one include family, which stays internal to the trunk); and
`subgraph_versions` is only `{"_global": <pot revision>}`, not per-subgraph
counters (see [`writing.md`](./writing.md) §5.2).

---

## 9. Read CLI commands (summary)

Full flag lists live in [`cli-flow.md`](./cli-flow.md); this is the read-side orientation.

| Command | Role |
|---|---|
| `graph status` | data-plane status for the active pot |
| `graph catalog [--subgraph] [--profile full\|read]` | **contract discovery** — returns `GraphCatalogResult`: versions, commands, truth classes, mutation ops (+ empty review/deferred), source authorities, `match_mode`, views, public entity types & predicates. Derived entirely from the ontology + view map + constants ("no docs needed"). `--task` is **accepted but ignored in V1.5**. |
| `graph describe [subgraph] [--view] [--examples]` | Context-free typed metadata operation with identical daemon and in-process semantics; returns a subgraph and optionally one view without selecting a pot or acquiring an engine lease. |
| `graph read --subgraph <s> --view <v>` | **Retrieve** axis (§7/§8); `--detail compact\|full`, `--relations summary\|full`, `--query`, `--query-threshold` (no default: omitted, the view's own filtering applies; lower is looser), `--scope k:v`, `--since/--until`, `--depth/--direction`, `--limit`, `--sort`, `--format`. `--detail` and `--relations` affect **both** `--json` and default human output. `--format table` renders markdown pipe tables in human mode; timeline views default to `--format events` (bullets). Out-of-range `--depth`, exact case/alias spellings of the selector and presentation flags, and an ignored `--time-window` execute once and are disclosed as `adjustments` (see [cli-flow.md](./cli-flow.md), *Adjusted reads*). |
| `timeline recent` | sugar for `graph read --subgraph recent_changes --view timeline`. |
| `graph search-entities` | **Filter** axis (§7); structured typed lookup for identity resolution. |
| `graph neighborhood --entity <key>` | **Traverse** axis (§7); `graph inspect <key>` is a legacy alias. |
| `graph history` | mutation/claim/entity history (audit trail). |

The compatibility `resolve`/`search` commands cover Retrieve, while `status` is
the composite readiness/nudge command. The workbench adds the Filter and Traverse
axes through `graph search-entities` and `graph neighborhood`.

---

## 10. The legacy `ContextGraphQuery` model

`ContextGraphQuery`/`ContextGraphResult` plus the `ContextGraphGoal`/`ContextGraphStrategy`
enums and `preset_*` helpers (`domain/graph_query.py`) are a **legacy/compat request
model**, not the canonical in-process read DTO. The canonical DTOs are `ResolveRequest`/
`SearchRequest` (`domain/ports/agent_context.py`) and `GraphReadRequest`.
`adapters/outbound/graph/context_graph_service.py` translates
`ContextGraphQuery → ResolveRequest → GraphService.resolve()` and carries `goal`/`strategy`
only in `metadata`. `ContextGraphGoal` now has only `RETRIEVE`/`TIMELINE` (`ANSWER`/
`INVESTIGATE` were removed); **`ContextGraphStrategy` (AUTO/SEMANTIC/EXACT/HYBRID/
TRAVERSAL/TEMPORAL) is consumed by nothing in the active read trunk — it is vestigial.**
This model survives only for the benchmark harness and a legacy port; the HTTP routers
return "unsupported legacy ContextGraphQuery endpoint." Do not treat the strategy enum as
a live read knob.

---

## See also

- [`ontology.md`](./ontology.md) — entities, predicates, subgraphs/views, truth classes, identity & the environment qualifier.
- [`writing.md`](./writing.md) — the write stack, propose→commit, revision-based concurrency and receipts, the read-back verify path that reuses these readers.
- [`cli-flow.md`](./cli-flow.md) — full command and flag reference.
- [`architecture.md`](./architecture.md) — the read trunk's place in the hexagonal layering and composition roots.
- [`ingestion-nudge.md`](./ingestion-nudge.md) — the zero-token nudge model, which composes this read trunk over named views.

---

### Context Graph Skills (`docs/context-graph/skills.md`)

---
title: Skills and harness-led intelligence
description: How coding harnesses use Potpie skills to read and write the graph.
---

## Overview

> Status: reflects the capability-first layout, last reviewed 2026-08-24.

Potpie does not own the reasoning that turns a repo, PR, ticket, or bug into
durable memory. That intelligence lives in the **user's coding harness** (Claude
Code, Codex, Cursor, OpenCode), running on the user's own model subscription, and
is taught to the harness through **Potpie skills**. Potpie validates, lowers,
commits, audits, and ranks; it does not infer rich facts from prose or scan a
repository for you. This is the product's central anti-goal made concrete: *no
Potpie-owned LLM/reconciliation agent as the canonical source of graph
intelligence.*

A skill is **pure instruction text** — a `SKILL.md` of markdown (no executable
code) that a harness loads into its context. Skills teach an agent *how to use the
`potpie` CLI*; they are never graph facts, and they add no new tools. The
`potpie-graph` skill states it plainly: "You are the intelligence that reads it
before acting and writes durable learnings after... It does **not** scan a
repository or infer rich facts from prose for you."

> **Two skill surfaces — never conflate them.** (1) The **user-installed bundle
> skills** that drive the `potpie graph …` CLI workbench, managed by
> `DefaultSkillManager`. (2) The **server-side reconciliation skills** loaded by
> the ingestion-server's deep agent (`pydantic_deep_agent.py`), with a different
> tool surface and install path, off by default. Everything from here to
> [§Server-side reconciliation skills](#server-side-reconciliation-skills-a-separate-surface)
> is surface (1); surface (2) is documented at the end and in
> [ingestion-nudge.md](./ingestion-nudge.md).

---

## 1. The skill catalog & packaging

The single source of truth for skill content **and** metadata is the bundled set
of `SKILL.md` files under
`potpie/cli/templates/agent_bundle/.agents/skills/*/SKILL.md`.

`potpie/skills/catalog.py` scans those templates at runtime
(`lru_cache`d), parses each file's YAML front-matter (`name` / `version` /
`description`, optional `recommended: false`) into a `SkillInfo`, and exposes
`catalog_by_id()` and `RECOMMENDED_SKILL_IDS` (every recommended bundled skill).
Adding or editing a skill means editing the bundled markdown — nothing else.

There are **11 skills in the agent bundle** (the 8 core skills in §3 plus three
per-format resource skills), and it is the only copy: every harness installs its
skills from it, remapped to the harness's own layout. The compact instruction
block merged into `AGENTS.md` / `CLAUDE.md` likewise has one source,
`templates/routing/POTPIE.md`.

## 2. Installation, targets & drift (`DefaultSkillManager`)

`potpie/skills/manager.py DefaultSkillManager` owns the catalog +
per-harness install/drift logic and delegates *where/how* to a registered
`AgentTargetPort` per harness. Operations: `list / install / update / remove /
status / nudge / add` (`add` is a TODO stub).

One table, `HARNESS_LAYOUTS` in `potpie/skills/harnesses.py`, says where each
harness reads Potpie's files at each scope. One target class,
`AgentTarget` in `potpie/skills/targets.py`, reads it for a harness at
`global` or `project` scope; `potpie/runtime/composition.py` registers one
global target per row, and a `--scope project` call builds a project target for
a registered harness.

| Harness (`--agent`) | Global skills | Global instructions | Project skills | Project instructions |
|---|---|---|---|---|
| `claude` | `~/.claude/skills` | `~/.claude/CLAUDE.md` | `.claude/skills` | `CLAUDE.md` |
| `codex` | `~/.agents/skills` | `~/.codex/AGENTS.md` | `.agents/skills` | `AGENTS.md` |
| `cursor` | `~/.cursor/skills` | — | `.cursor/skills` | `AGENTS.md` |
| `opencode` | `~/.config/opencode/skills` | — | `.opencode/skills` | — |

`default` is an alias of `codex` for the repository bundle an embedding host
installs with `install_agent_bundle()`; it is not a harness the skills CLI
manages, and `potpie setup --agent default` skips the skills step.

Global roots hang off `POTPIE_HARNESS_HOME` when it is set (the test suite pins
it), otherwise the real home directory; `CONTEXT_ENGINE_HOME` deliberately does
not move them.

Install mechanics (`potpie/skills/installer.py`, one `install_bundle` and one
`uninstall_bundle`; both read the bundle through `potpie/skills/bundle.py`):

- Every harness installs the same skill files, placed under its skills
  directory from the table.
- `AGENTS.md` / `CLAUDE.md` are **merged**, not overwritten — managed content
  lives between `` / `` markers
  (`_merge_managed_markdown`), preserving the user's own instructions.
- **The routing block belongs to the sweep.** It is written only by a bundle
  install or update (no skill id), and the instruction file it went into is
  named in the result's `metadata.support_files`; naming one skill id installs
  that skill alone. `skills remove --all` takes the same block back out — the
  managed section only, so a user's own `CLAUDE.md` text survives.
- **Retired Claude files.** Earlier releases also wrote two slash commands into
  a repository's `.claude/commands/` and offered a Claude Code plugin directory
  under `.claude/`; neither ships now. A project-scope Claude sweep (install or
  update without an id, or `remove --all`) deletes a leftover command file only
  when its bytes match a version Potpie shipped
  (`metadata.retired_files_removed`). An edited command file, or the old plugin
  directory (recognised by a manifest naming `potpie`), is never deleted: it is
  listed under `metadata.leftovers` with the step that clears it.
- **Drift tracking:** each target keeps one JSON manifest in the Potpie home
  (`skill_manifest_<agent>_<scope>.json`, plus a per-repository suffix at
  project scope) recording, per skill, the installed version, a content hash
  and the disabled flag. The three per-target files earlier releases wrote
  (`skills_…`, `skill_hashes_…`, `skill_disabled_…`) are folded into it on
  first read and then deleted; an unreadable one contributes nothing and
  nothing crashes. A skill whose files no longer match the bundle is
  **drifted** — reported inside `outdated` and fixed by the same reinstall —
  while a hand-edited one is left alone by a sweep
  (`metadata.preserved_user_edits`). A skill removed by id is **disabled**:
  bundle installs skip it until it is installed by id again. `status()`
  partitions skills into installed / missing / outdated / disabled, and
  `potpie --json skills status` also lists the `drifted` subset; `nudge()` emits
  the single advisory command
  `potpie skills install --agent <agent>`. That advisory is the *only* skill
  signal agents ever see — it rides on `context_status` (see
  [querying.md](./querying.md)). Globally-installed skills can stale (an old
  `potpie-graph` that still teaches the legacy `graph mutate` predates v5);
  `potpie skills update --agent <a>` is the fix the drift nudge points at.

### The correctness gate (this is real, not aspirational)

`validate_packaged_skill_command_snippets` (`potpie/skills/snippets.py`)
extracts every `potpie …` command a packaged template teaches — lines in
```` ```bash ```` fences and inline `` `potpie …` `` spans — and validates each
command + option against a command table. `tests/unit/test_agent_skill_templates.py`
runs it at build time with the **live Typer specs introspected from
`potpie.cli.main.app`**, so a skill cannot ship a `potpie` command or flag that
does not exist; installs no longer import the CLI to re-check the bundle the
build already checked. (Only `potpie` commands are checked; other shell commands
depend on the user's repo.)

### CLI surface

```bash
potpie skills list   [--agent claude|codex|cursor|opencode] [--scope global|project] [--path]
potpie skills install [<id>] [--agent …] [--scope …] [--path]
potpie skills update  [<id>|--all] [--agent …] [--scope …] [--path]
potpie skills status  [--agent …] [--scope …] [--path]     # installed, missing, outdated, drifted, disabled
potpie skills remove  [<id>|--all] [--agent …] [--scope …] [--path]
potpie skills add     <source>        # TODO stub
```

`--scope` flips to `project` automatically when `--path` is given with `global`;
`--path` is resolved against the caller's working directory and must already
exist. Skill commands are filesystem-only and never contact the daemon
(`install` still accepts a hidden, no-op `--no-daemon` for older installers).
`potpie setup --agent <harness>` installs the recommended bundle during first
run. **There is no top-level `potpie install`** — skills install only via
`potpie skills install` (and `setup`). Full flags live in
[cli-flow.md](./cli-flow.md).

---

## 3. The 8 core bundle skills

| Skill | Ver | Role |
|---|---|---|
| `potpie-cli` | v6 | The `potpie` command itself: pot-scope resolution order, harness-led boundaries. |
| **`potpie-graph`** | **v9** | **THE contract skill** — one shared discovery pass, the read → resolve → record or propose/commit → inbox → quality loop, ontology selection, truth classes, retrieval-grade descriptions, reporting the commands behind an answer, and "Responding To Nudges". Teaches `potpie record` for one fix or note and **propose/commit** for everything else (never the legacy `graph mutate`). |
| `potpie-repo-baseline` | v3 | Deep repo-baseline mode: source priority, evidence matrix, canonical entity families with `PROVIDES` / `IMPLEMENTED_IN`. |
| `potpie-source-ingestion` | v5 | Todo-driven, phased (0–8) ingestion of a repo/PR/ticket/doc; parallel read-only subagents; GitHub/Linear/Jira hydrated via the agent's **own** integration tools (explicitly *not* Potpie connector queueing) → evidence matrix → identity resolution → propose/commit `--verify` → quality gate. |
| `potpie-project-preferences` | v3 | Use-case read+record skill (preferences). |
| `potpie-infra-architecture` | v5 | Use-case read+record skill (infra/topology). |
| `potpie-change-timeline` | v3 | Use-case read+record skill (recent changes). |
| `potpie-debug-memory` | v5 | Use-case read+record skill (prior bugs/fixes). |

Three per-format skills handle document payloads: `potpie-resource-pdf`,
`potpie-resource-spreadsheet` and `potpie-resource-markdown` (v1 each). Each
teaches the agent to write an extraction script that emits a chunk directory,
import it with `potpie resource import`, summarize its sections, and link the
document to what it covers; see the resource store in
[`architecture.md`](./architecture.md).

The four use-case skills share one shape: a **Fast Path** read, an **Apply
Results** step, a **Report Back** step (the exact commands behind the answer,
and a diagram only when the answer is a shape), and a **Record** flow over the
CLI.

---

## 4. `potpie-graph` v9 — the taught read/write loop

This is the contract skill: it points the agent at the *live* catalog rather than
baking the ontology into prose. The discipline it teaches (full read mechanics in
[querying.md](./querying.md), full write mechanics in [writing.md](./writing.md)):

1. **Discover once, in parallel.** One shared discovery pass across skills:
   `potpie resolve` (the intent is inferred from the task text unless `--intent`
   names one),
   scope-only `preferences_for_scope --repo current` for code work, and an
   untyped `graph search-entities` for a named entity with an unknown key.
   Before ingestion, `graph catalog --profile full` supplies the live ontology
   (entity types, identity policies, predicates, allowed endpoints) — derived
   at runtime, so no docs are needed.
2. **Read** over the fixed view table: `graph read --subgraph <s> --view <v>`.
   **Query expansion is the agent's job** — the bundled local embedder is small,
   so the agent broadens the user's words ("add retry to payments client" → also
   carry "timeout, flaky, backoff, external call") in-session, not the daemon.
   Always inspect `coverage` / `freshness` / `quality` before relying on results.
3. **Resolve identity *before* writing.** `graph search-entities "<name>" --type …
   --source-ref …`, then reuse the returned canonical `key`. Inventing a
   near-duplicate (`service:payments` vs `service:local:payments-api`) fragments
   the graph and breaks future reads.
4. **Write through the canonical two-phase door.** One fix or free-form note
   is one `potpie record` call (`--type`, `--summary`, `--scope`); everything
   structured is `graph propose --file mutation.json` →
   `graph commit <plan_id> --verify` (`--approved-by` for a `review_required`
   plan).
   `graph mutation-template --kind <…>` gives a schema-only skeleton to fill from
   sources actually read. **Never hard-delete** — use validity / retraction /
   supersession / merge. Pick the truth class honestly (it feeds the ranker).
   `graph mutate` exists only as a **legacy wrapper** over propose+commit and the
   skill steers away from it.
5. **Capture uncertainty** that isn't yet a safe fact: `graph inbox add`. Inbox
   items are pending work, never returned as graph facts until processed.
6. **Inspect quality (read-only):** `graph quality {summary | duplicate-candidates
   | stale-facts | conflicting-claims | orphan-entities | low-confidence |
   projection-drift}`. Repair through propose/commit or park in the inbox; quality
   never writes.

Protocol questions (message layouts, field values, decoders) take a separate
route. The skill checks that the catalog advertises `protocols.message_context`,
which needs the opt-in `graph.protocols` key. It then discovers messages with
`resolve --include protocols` and follows its bundled
`references/protocols.md`. The repo-baseline, source-ingestion,
infra-architecture and debug-memory skills point at the same reference.

> **The one rule the skill emphasizes most:** every entity and claim carries a
> `description` written as a **retrieval card** — the symptoms, synonyms, and
> scope a *future searcher* would type, not display text. Validation only *warns*
> on a weak card, but a vague description means the fact never resurfaces. Weak:
> `"deadlock fix"`. Strong: `"Concurrent refund + settle deadlocks payments DB
> under load; seen as 'refund race timeout'; fixed by ordering lock acquisition
> in services/payments/settle.py"`.

---

## 5. Nudges: the agent's half of the loop

Potpie ships no hook adapter. A harness that wants nudges wires its own
lifecycle hooks to `potpie --json graph nudge --event <e> --session <id>`
and injects the result; the trigger model, the executor, dedup, and the event
mapping a harness should use are owned by
[ingestion-nudge.md](./ingestion-nudge.md).

`potpie-graph` → "Responding To Nudges" teaches what to do with an injected
result:

- **`inject_context`** → ranked graph truth already scoped to the task; use it
  directly rather than re-fetching.
- **`instruction`** (e.g. "you resolved `<error>` after editing `<files>` —
  record the bug+fix if non-obvious") → a *prompt to decide*, **not** an
  auto-write. The agent picks the truth class, resolves identity, writes a
  retrieval-grade description, then propose/commit or inbox. Writes are
  idempotent by `idempotency_key`, so a repeat capture never duplicates.

---

## Server-side reconciliation skills (a separate surface)

`adapters/outbound/reconciliation/skills/` holds **`backfill-enumerate-drain`**
and **`graph-mutation-plan`**. These are **not** part of the user-installed
bundle, are **not** managed by `DefaultSkillManager`, and are **never installed
into a harness**. They belong to the server-side reconciliation deep agent
(`adapters/outbound/reconciliation/pydantic_deep_agent.py`), which loads them via
its own `list_skills` / `load_skill` toolset from `_SKILLS_DIR` and writes
through a **different tool surface**: `apply_graph_mutations(plan, event_id,
summary)`, `mark_event_processed`, and `finish_batch`.

Same philosophy (a model authoring semantic mutations, downgrading to
`RELATED_TO` / `Document` / `Observation` when uncertain, with `domain/ontology.py`
as the entity-key source of truth) — but a distinct agent, tool surface, and
install path.

> **Roadmap (not yet wired):** this reconciliation agent runs only on the
> separate HTTP ingestion-server composition root and is **off by default**
> (`domain/reconciliation_flags.py agent_planner_enabled()` returns False; opt-in
> `CONTEXT_ENGINE_AGENT_PLANNER_ENABLED=1`). The canonical write path remains
> harness-authored semantic mutations + the deterministic record bridge. See
> [ingestion-nudge.md](./ingestion-nudge.md).

---

## See also

- [ingestion-nudge.md](./ingestion-nudge.md) — the zero-token nudge trigger model a harness calls through `graph nudge`, and the server-side reconciliation pipeline.
- [querying.md](./querying.md) — the read mechanics (catalog/read/search-entities and the AgentEnvelope) the skills drive.
- [writing.md](./writing.md) — the propose → commit `--verify` write door, the semantic DSL, inbox, and quality scoring.
- [cli-flow.md](./cli-flow.md) — the full `potpie skills` and `potpie graph …` command/flag reference.

---

### Context Graph Vision (`docs/context-graph/vision.md`)

---
title: Context graph vision
description: "Why the Context Graph exists, claims not payloads, and product boundaries."
---

## Overview

The Context Graph is Potpie's **durable, shared project memory for AI agents** — a
compact, sourced store of facts about a project (decisions, ownership, topology,
prior bugs and fixes, conventions, features) so an agent does not rebuild context
from raw code, PRs, tickets, docs, and chat on every task. The root README frames
it as turning "your codebase and software development lifecycle into a living
context graph for AI agents."

This doc covers **what the Context Graph is and why it exists**. The mechanics live
in sibling docs: layers and composition roots in [architecture.md](./architecture.md),
the vocabulary in [ontology.md](./ontology.md), reading in [querying.md](./querying.md),
writing in [writing.md](./writing.md), how raw events arrive and the nudge model in
[ingestion-nudge.md](./ingestion-nudge.md), and the harness skills in
[skills.md](./skills.md).

## The Problem

Project context is scattered:

- decisions live in PRs, ADRs, Slack threads, and tickets;
- ownership and topology live in repo files, dashboards, and tribal memory;
- prior bugs and fixes are hard to retrieve by symptom;
- agents start each task with no durable memory of what previous agents learned.

Potpie should hold that context once, keep it fresh, and expose it through one
small, stable surface that both humans and agents use.

## Claims, not payloads

The graph stores **compact claims plus source references, never the payloads
themselves**. It deliberately does *not* hold full PR diffs, document bodies, chat
transcripts, logs, webhook archives, or telemetry. A *claim* is a sourced,
time-stamped fact about an entity or a relationship; a *source ref* points back to
the evidence (a file path, PR, ticket, doc URL, deploy).

The canonical claim store is the single source of truth. Everything else —
semantic search indexes, traversal/inspection views, analytics rollups — is a
**rebuildable projection** off those claims. The governing storage principle:

> The graph model is the invariant. Physical storage is an adapter.

```mermaid
flowchart LR
  cg_claims[("canonical claims<br/>(source of truth)")]
  cg_semantic[("semantic index")]
  cg_inspection[("traversal / inspection views")]
  cg_analytics[("analytics rollups")]

  cg_claims --> cg_semantic
  cg_claims --> cg_inspection
  cg_claims --> cg_analytics
```

The full vocabulary — 25 entity types, 27 public predicates plus a `RELATED_TO`
fallback, 7 truth classes — is owned by [ontology.md](./ontology.md). PRs, commits,
issues, incidents, and deployments all collapse to a single timeline `Activity`
entity; an entity exists only if an edge needs it as an endpoint.

## One CLI surface for humans and agents

There is **no separate human-vs-agent API**. Both users and agents talk to the same
`potpie` CLI. Engine operations route through the same finite typed operation
catalog whether they run through `LocalEngineClient` or `DaemonEngineClient`;
root capability commands use explicit root-owned services.

Agents reach the system directly through the CLI, including the full **Graph
Surface Lite** (`potpie graph catalog/read/search-entities/propose/commit/…`).
Read mechanics live in [querying.md](./querying.md); write mechanics in
[writing.md](./writing.md); the full command reference in [cli-flow.md](./cli-flow.md).

## Harness-owned intelligence

The intelligence lives in the **user's coding harness** (Claude Code, Codex,
Cursor, OpenCode), taught via **Potpie skills** — not in a Potpie-owned
reconciliation or LLM agent. Skills teach an agent how to use the CLI; they are not
graph facts and not new tools. The anti-goal is verbatim: *"No Potpie-owned
LLM/reconciliation agent as the canonical source of graph intelligence."*

The code honors this:

- The **nudge brain is deterministic** (`NudgeService`, wired by the root runtime
  composition; no model on this path).
- Retrieval ships a **bundled local embedder by default**, so semantic search needs
  no API key (disable with `CONTEXT_ENGINE_EMBEDDER=none`).
- A service-side reconciliation agent exists only as an optional extra on the HTTP
  ingestion-server path, and it is **off by default**
  (`reconciliation_flags.agent_planner_enabled()` returns `False`).
- Writes never bypass validation: an ambiguous request becomes an **inbox item or a
  low-authority observation** for a harness skill to process, never an unsafe
  canonical fact.

See [skills.md](./skills.md) for the skill catalog and the read/write loop the
skills teach.

## Product Shape

```mermaid
flowchart TB
  cg_agent["agent harness<br/>(skills + CLI)"]
  cg_cli["potpie CLI → typed clients / root services → ports"]
  cg_graph[("Context Graph<br/>compact claims + source refs")]
  cg_managed["Managed Potpie graph<br/>(roadmap: not yet wired)"]
  cg_ledger["External Event Ledger<br/>(roadmap: client stubs)"]

  cg_agent --> cg_cli
  cg_cli --> cg_graph
  cg_graph --> cg_cli
  cg_cli -. "swap wiring (unbuilt)" .-> cg_managed
  cg_ledger -. "pull / consume (unbuilt)" .-> cg_graph
```

There are three intended product boundaries. Only the first is shipped today.

| Boundary | Status | Description |
|---|---|---|
| **Local OSS self-serve** | **Shipped (V1.5)** | Installed with the CLI. `potpie setup` provisions config, local stores, the active `default` pot, source registration, the daemon, and skills. State stays local by default. Default backend `falkordb_lite` (no Docker/Neo4j/cloud needed). |
| **Managed Potpie graph** | **Roadmap** | A managed backend API hosting the same engine-owned domain on hosted stores with hosted auth/collaboration. The graph model is identical; deployment-specific composition and clients provide the boundary. |
| **Event Ledger** | **Roadmap** | A separate managed-or-self-hostable source-event service (webhooks, polling, replay cursors) that local or managed graphs *pull* from; it is never the graph source of truth. |

> **Roadmap (not yet wired):** Managed routing is designed and wired-for but not
> functional — `potpie use --managed`, `pot list --managed`, and the entire `cloud`
> command group raise `CapabilityNotImplemented`. The external Event Ledger clients
> (`managed_client.py` / `self_hosted_client.py`) are TODO stubs, so `potpie ledger
> pull/query` is non-functional against any real provider. The live "ledger" today
> is the internal Postgres event store used by ingestion — see
> [ingestion-nudge.md](./ingestion-nudge.md) for the two-ledger distinction.

Local and managed share **one graph model** ("no separate local and cloud graph
models"). The Event Ledger is adjacent infrastructure for source events, not a
second graph model.

## Pots as tenancy

A **Pot is the unit of isolation and tenancy** — every query, source, inbox item,
claim, semantic mutation, and graph operation is scoped to exactly one pot. The pot
id **is** the storage `group_id` stamped on every entity node and relationship edge
(`reset_pot` is a `MATCH (n {group_id:$gid}) DETACH DELETE n`). A pot can be local
or managed; the active pot determines routing. First local setup creates an active
local `default` pot. **Cross-pot federation is an explicit anti-goal.**

## Core Model

| Concept | Meaning |
|---|---|
| **Pot** | Unit of isolation and tenancy; `pot_id` is the storage `group_id`. A pot lives in the local daemon or (roadmap) a managed backend; the CLI addresses both through the same pot surface. First local setup creates the active `default` pot. |
| **Entity** | A stable project object — e.g. a `Service`, `Feature`, `Decision`, `Dependency`, `Person`/`Team`, or a timeline `Activity` (PRs/commits/issues/incidents/deployments collapse here). One of 24 catalog types; minted only when an edge needs it as an endpoint. |
| **Claim** | A canonical, sourced, time-stamped fact about an entity or relationship, carrying a truth class and provenance. The single source of truth. |
| **Source ref** | A pointer back to evidence: file path, PR, ticket, doc URL, or deploy. The graph holds the ref, not the payload. |
| **Semantic mutation** | An agent-facing structured write proposal (flat ops over a small DSL) that validates and lowers into graph writes. |
| **Inbox item** | Pending graph work captured when the agent or user is not yet ready to commit an ontology update; never a fact until processed. |
| **Event** | A source-system change (merged PR, ticket update, deploy). Raw events land in an internal event store and are turned into claims by ingestion — they are not stored verbatim. |

## What's shipped now (V1.5)

The single biggest historical drift in these docs framed the `potpie graph …`
workbench as a future "Graph V2." **That is wrong: the workbench is shipped today
as V1.5.** Both the legacy V1 wrappers (`resolve` / `search` / `record`) *and* the
full workbench are registered in `main.py` right now.

Concretely, the live contract constants are:

| Constant | Value | Meaning |
|---|---|---|
| `GRAPH_CONTRACT_VERSION` | `v1.5` | the data plane (what's shipped) |
| `ONTOLOGY_VERSION` | `2026-06-graph` | the catalog/vocabulary version |
| `GRAPH_WORKBENCH_CONTRACT_VERSION` | `v2` | **only** the workbench *envelope* version string — not a separate, unshipped product |

So "v2" survives solely as the envelope version on workbench responses. What ships:

- **Reads:** `potpie resolve/search` and `potpie graph catalog/read/search-entities/
  neighborhood/history` over the single read trunk; reads return ranked evidence
  (an `AgentEnvelope`) — **there is no server-side answer synthesis**, the agent
  reasons over the evidence ([querying.md](./querying.md)).
- **Writes:** the canonical write door is **`graph propose` → `graph commit
  --verify`**. `graph mutate` is a **legacy wrapper** that internally calls
  propose+commit; `potpie record` remains the compatibility write path
  ([writing.md](./writing.md)).
- **Defaults:** `falkordb_lite` embedded backend, a detached `daemon` host mode, the
  bundled local embedder, installed skills, and the zero-token `graph nudge` a
  harness can call from its own hooks — all
  with no mandatory Docker, Neo4j, Postgres, or cloud service.

The canonical local first-run is one command (`setup` registers the repo as a
source; it never scans the working tree — the harness fills the graph):

```bash
potpie setup --repo . --agent claude
potpie status
```

After setup, an agent discovers the contract (`graph catalog`), reads bounded views
(`graph read`), resolves identity (`graph search-entities`), and writes durable
learnings (`graph propose` → `graph commit --verify`). See
[cli-flow.md](./cli-flow.md) for the full surface.

## Anti-goals

- No separate local and cloud graph models.
- No second long-term agent surface beyond the workbench.
- No write path that bypasses semantic validation and commit.
- No Potpie-owned LLM/reconciliation agent as the canonical source of graph
  intelligence.
- No source-provider credentials in the local daemon by default.
- No full source payloads in the graph.
- No Event Ledger as the graph source of truth.
- No hidden cloud dependency for local use.
- No mandatory Docker, Neo4j, Postgres, or cloud embedding service for OSS.
- No daemon shell as a dumping ground for business logic.
- No cross-pot federation in this design.

## See also

- [architecture.md](./architecture.md) — hexagonal layers, composition roots, the daemon, backends.
- [ontology.md](./ontology.md) — the 24 entities / 26 predicate keys / truth classes / contract constants.
- [querying.md](./querying.md) — the two read altitudes, the read trunk, ranking, retrieval cards.
- [writing.md](./writing.md) — the semantic DSL, propose→commit, the legacy `mutate` wrapper, inbox.
- [ingestion-nudge.md](./ingestion-nudge.md) — how raw events arrive, the two ledgers, the nudge model.
- [skills.md](./skills.md) — harness-owned intelligence and the skill read/write loop.
- [cli-flow.md](./cli-flow.md) — the full `potpie` command reference.

---

### Context Graph Writing (`docs/context-graph/writing.md`)

---
title: Writing the Context Graph
description: "Semantic mutations, propose and commit, inbox, and quality checks."
---

## Overview

How facts get *into* the graph. Reads are covered in [querying.md](./querying.md); the
static contract (entities, predicates, truth classes, ops) lives in [ontology.md](./ontology.md);
the full command/flag surface is in [cli-flow.md](./cli-flow.md). This doc owns the **write
path**: the tiered stack, the flat semantic DSL, the two write spines, the single backend
write door, the inbox, coherence, and quality scoring.

Two framing rules up front:

- **The `potpie graph …` workbench is shipped today** as V1.5
  (`GRAPH_CONTRACT_VERSION="v1.5"`, `ONTOLOGY_VERSION="2026-06-graph"`; the workbench *envelope*
  stamps `graph_contract_version="v2"` only as a transport version string). There is no
  "future Graph V2" write surface — propose/commit are live.
- **The canonical write door is `graph propose` → `graph commit --verify`.**
  `graph mutate`, direct semantic writes, and `record` / `context_record` use the
  same persisted preparation and authoritative execution contract.

---

## 1. The tiered write stack

Every write — whether authored by a harness skill, the `record` bridge, or the (off-by-default)
server-side reconciliation agent — funnels through the same tiers. Agents emit *semantic*
operations; Cypher and structural edge/entity DTOs are internal.

```mermaid
flowchart TD
  w_dsl["Semantic DSL<br/>{pot_id, operations:[...]}<br/>domain/semantic_mutations.py"]
  w_val["Validation + Risk<br/>(pure domain)<br/>semantic_mutation_validator.py"]
  w_low["Lowering<br/>accepted ops → MutationBatch<br/>semantic_mutation_lowering.py"]
  w_spineA["Spine A: propose → commit(--verify)<br/>graph_workbench.py"]
  w_spineB["Spine B: mutate (immediate)<br/>graph_service.py — only via record/context_record"]
  w_pre["Pre-apply validation / soft-fail downgrade<br/>reconciliation_validation.py"]
  w_door["Single write door<br/>apply_mutation_batch — 4 verbs<br/>apply_plan.py"]
  w_store["Position-B canonical store<br/>(:Entity)-[:RELATES_TO]->(:Entity)<br/>cypher.py"]

  w_dsl --> w_val --> w_low
  w_low --> w_spineA
  w_low --> w_spineB
  w_spineA --> w_pre
  w_spineB --> w_pre
  w_pre --> w_door --> w_store
```

| Tier | Module | Responsibility |
|---|---|---|
| Semantic DSL | `domain/semantic_mutations.py` | Parse flat JSON ops into frozen DTOs |
| Validation + risk | `application/services/semantic_mutation_validator.py` | Pure-domain checks; per-op `MutationRisk`; batch decision |
| Lowering | `application/services/semantic_mutation_lowering.py` | Accepted ops → `MutationBatch` + provenance; stamp claim props |
| Spine A (canonical) | `application/services/graph_workbench.py` | propose → persist plan → commit(--verify) |
| Spine B (immediate) | `application/services/graph_service.py` | `mutate()` — validate→lower→apply now (record bridge only) |
| Pre-apply gate | `application/services/reconciliation_validation.py` | Caps, canonicalization, soft-fail downgrade |
| Write door | `adapters/outbound/graph/apply_plan.py` | `apply_mutation_batch` — 4 verbs, idempotent fingerprint |

---

## 2. The semantic DSL (flat ops)

Agents emit a batch-shaped payload — never Cypher, never `EntityUpsert`:

```json
{ "pot_id": "<pot-id>", "operations": [ { "op": "...", ... }, ... ] }
```

`SemanticMutationRequest.parse` / `SemanticMutation.parse` (`domain/semantic_mutations.py`) turn
this into frozen DTOs. A single-op alias `{ "op": "...", ... }` (also accepts `"operation"`) is
normalized to a one-element batch at parse. `--pot` on the CLI overrides any baked-in `pot_id`.
Structural parse failures raise `SemanticMutationParseError` (these are *shape* errors only;
ontology/authority checks happen in the next tier).

### 2.1 The 10 operations — all `APPLICABLE`

`SemanticMutationOp` (`domain/graph_contract.py`) defines exactly ten ops, and
**`APPLICABLE_MUTATION_OPS` is all ten**. There is no `reconcile_snapshot` op — that name is
fictional (it survives only as a stale comment in `domain/reconciliation.py`).

| Op | Use for | Lowers to |
|---|---|---|
| `upsert_entity` | Stable entity metadata (name/summary/description/properties) | `EntityUpsert` only |
| `link_entities` | Authoritative typed relationship | `EdgeUpsert` |
| `assert_claim` | Evidence-backed inference (the default agent write) | `EdgeUpsert` (value → synthetic `Observation`) |
| `append_event` | Timeline activity (PR/commit/deploy/incident) | `Activity` anchor + PERFORMED/TOUCHED/MENTIONS edges |
| `end_relation_validity` | Soft-end a relation with `valid_until` | `InvalidationOp` |
| `retract_claim` | Invalidate a claim (needs `reason`) | `InvalidationOp` |
| `supersede_claim` | Replace a claim, preserving history | replacement `EdgeUpsert` + `InvalidationOp(superseded_by_key)` |
| `merge_duplicate_entities` | Identity cleanup | merge props + a `RELATED_TO` merge-record edge |
| `patch_entity` | Small property update (field allow-list) | entity-only `EntityUpsert` |
| `transition_state` | Lifecycle field change (state machine) | lifecycle props + minted `Activity` MENTIONS edge |

> **Crucial:** there is no op partition for review. `REVIEW_REQUIRED_OPS` and `DEFERRED_OPS`
> are **both empty tuples**. Review/blocking is decided **at runtime** by `MutationRisk`
> (low/medium/high) — see §3 — not by which op you used. Old docs that called `supersede_claim`
> or `merge_duplicate_entities` "usually review-required" are wrong: they auto-apply when
> `graph propose --approved-by <user-ref>` pre-approves the plan (the legacy `graph mutate`
> spells it `--allow-review-required --approved-by`), otherwise they return `review_required`
> because of their runtime risk and `graph commit <plan_id> --approved-by <user-ref> --verify`
> applies them.

### 2.2 Op fields are FLAT

Every field is **top-level on the operation** — there are no nested `{"event":{…}}`,
`{"claim":{…}}`, or `{"field"/"from"/"to"}` wrappers. Nested shapes (as in the old `graphv2.md`
examples) **will not parse**.

The flat field set: `op`, `subject`, `predicate`, `object`, `value`, `truth`, `confidence`,
`evidence[]`, `description`, `environment`, `valid_from`, `valid_until`, `observed_at`, `reason`,
`superseded_by`, `patch`, `expected_entity_version`, `from_state`, `to_state`, `external_ids`.
`append_event` adds `verb`, `occurred_at`, `actor`, `targets[]`, `mentions[]`. `subject` and
`object` may be a bare key string or an object carrying `key`/`type`/`name`/`summary`/
`description`/`properties` (so a single op can mint the endpoint and assert about it at once).

A real bug→fix proposal (matches `graph mutation-template --kind bug-fix`):

```json
{
  "pot_id": "<pot-id>",
  "idempotency_key": "bug-fix:<bug-slug>:<fix-hash>",
  "created_by": { "surface": "cli", "harness": "claude" },
  "operations": [
    {
      "op": "assert_claim",
      "subject": {
        "key": "bug_pattern:<bug-slug>",
        "type": "BugPattern",
        "summary": "<one-line symptom>",
        "description": "<retrieval card: error text, symptoms, synonyms, where it shows up>"
      },
      "predicate": "REPRODUCES",
      "object": { "key": "service:<service-slug>", "type": "Service" },
      "truth": "agent_claim",
      "confidence": 0.8,
      "description": "<how the bug manifests>"
    },
    {
      "op": "assert_claim",
      "subject": { "key": "fix:<fix-hash>", "type": "Fix", "summary": "<one-line fix>" },
      "predicate": "RESOLVED",
      "object": { "key": "bug_pattern:<bug-slug>", "type": "BugPattern" },
      "truth": "agent_claim",
      "confidence": 0.8,
      "description": "<retrieval card: what fixed it, files touched, verification>"
    }
  ]
}
```

A timeline event (`append_event`, flat verb/occurred_at/actor/targets):

```json
{
  "pot_id": "<pot-id>",
  "operations": [
    {
      "op": "append_event",
      "verb": "merged_pr",
      "occurred_at": "2026-06-05T01:35:00+05:30",
      "description": "<what changed, source title, regression keywords — written for timeline recall>",
      "actor": { "key": "person:<handle>", "type": "Person" },
      "targets": [ { "key": "service:<service-slug>", "type": "Service" } ],
      "mentions": [ { "key": "feature:<feature-slug>", "type": "Feature" } ],
      "evidence": [ { "source_ref": "github:pr:acme/payments:812", "authority": "external_system" } ]
    }
  ]
}
```

`graph mutation-template --kind <repo-baseline|feature|preference|preference-policy|infra-snapshot|bug-fix|decision|timeline-event|timeline-change>`
prints a schema-only skeleton (placeholders only — it never reads the repo or infers facts).

The internal structural tier (`domain/graph_mutations.py`: `EntityUpsert`, `EdgeUpsert`,
`EdgeDelete`, `InvalidationOp` + `ProvenanceRef`/`ProvenanceContext`) is what these flat ops
*lower into*; agents never author it directly. Those compose a `MutationBatch`
(= `ReconciliationPlan`, `domain/reconciliation.py`).

---

## 3. Validation + risk

`validate_semantic_request(request) -> SemanticMutationPlan`
(`application/services/semantic_mutation_validator.py`) is **pure domain** — it reads the
ontology/contract and never touches a backend. Per-op checks:

- op is known; (a live but **dead** `deferred` branch, since `DEFERRED_OPS` is empty);
- truth class is valid; `confidence ∈ [0,1]`; timestamps are ISO-8601; evidence authorities valid;
- per-op structural rules: claim endpoints via `edge_spec.allows`; `append_event` needs an
  `Activity` anchor + PERFORMED/TOUCHED/MENTIONS endpoints; retract/supersede need a target
  identity; `patch_entity` enforces a field allow-list (rejects state fields) and a
  retrieval-grade description; `transition_state` validates the lifecycle state machine; merge
  requires distinct keys, same type, and `external_ids`.

The **evidence-or-low-authority** rule: only `authoritative_fact` and `source_observation`
claims must carry evidence; `agent_claim`/`quality_finding` are explicitly soft and need none.
A **missing `description` is only a warning, never a reject** — but recall depends on the
agent-authored retrieval card, so skills treat it as mandatory.

Each op becomes a `LoweredOperation` with a status (`accepted | review_required | deferred |
rejected`) and a `MutationRisk` (low/medium/high). The batch-level `_decide`:

- any error → whole batch `rejected`;
- any review op → `review_required`;
- medium/high-risk accepted ops auto-apply **only** with `allow_review_required AND approved_by`,
  otherwise `review_required`.

**Atomic batch semantics:** if *any* op can't auto-apply, the **whole batch is `review_required`
and nothing writes.** Subgraph routing (`_subgraph_for` / `subgraph_for_predicate`) maps each
predicate to its named slice. (Because the op partitions are empty, the validator's
`review_required`/`deferred` op branches are currently dead code — review is always the runtime
risk decision above.)

---

## 4. Lowering

`lower_semantic_request(request, plan)`
(`application/services/semantic_mutation_lowering.py`) lowers **only accepted ops** into
`plan.batch` + `plan.provenance`. Highlights:

- claims emit an `EdgeUpsert`; a `value` literal mints a synthetic `Observation` carrying the
  literal — "never an authoritative fact from raw text";
- `append_event` anchors an `Activity` and emits PERFORMED/TOUCHED/MENTIONS edges;
- `retract_claim`/`end_relation_validity` → `InvalidationOp`; `supersede_claim` writes the
  replacement claim **and** an invalidation stamped with `superseded_by_key`;
- `merge_duplicate_entities` stamps merge props + a `RELATED_TO` merge-record edge;
  `patch_entity` is entity-only; `transition_state` stamps lifecycle props and mints an Activity
  MENTIONS edge.

`_claim_properties` stamps the **full V1.5 claim metadata** onto every edge: `claim_key`,
`subgraph`, `truth`, `evidence_strength`, `confidence`, `fact`, `description`, `source_refs`/
`evidence`, `valid_at`/`valid_from`, `observed_at`, `created_by`, contract/ontology version,
`idempotency_key`, the `identity_key` tuple, `environment`, `code_scope`, and structured fields.
Entities are deduped by key (`_ensure_entity`); summaries are derived only from authored material
(`compact_entity_summary`), so a bare re-reference of an entity never clobbers a stored summary.

---

## 5. Spine A — propose → commit (the canonical door)

`GraphWorkbenchService` (`application/services/graph_workbench.py`) implements the two-phase door.

**`propose(payload, pot_id, ttl)`** — snapshot `current_versions`, compute `expected_versions`,
detect a version conflict, then parse → validate → (unless invalid/conflict) lower → build a
`GraphMutationDiff` + `claim_keys`, and persist a **`GraphMutationPlanRecord`** (its lowered
batch, provenance, expected/current versions, TTL expiry, warnings, rejected ops). Status is one
of `validated | invalid | conflict | review_required`. Returns a `GraphMutationProposal`.
**No graph write happens here.**

**`commit(plan_id, pot_id, approved_by, verify)`** — load by id; reject if not found / terminal /
expired; **re-check the version conflict**; enforce approval (medium/high risk needs
`--approved-by`, on either `propose` or `commit`); then call `backend.mutation.apply(record.lowered_batch, …)`. The agent does
**not** resend the mutations — commit replays the server-persisted plan. On success it persists
status `committed` with the `mutation_id` and final versions and emits a `history_pointer` +
`audit_ref`.

`--verify` runs `_verify_ingestion_commit`: it reads the committed `claim_keys` back through
`claim_query.find_claims` (flagging `missing_claim_keys`), takes before/after quality snapshots,
and **downgrades** the result to `degraded` / `partial` / `watch` on missing readback, an
unavailable backend, or a quality regression. A missing committed claim exits 1; a quality
regression only lands in `verification.status` and warnings, so read both the exit code and the
receipt. Skills always commit with `--verify`.

### 5.1 Plan states

`GraphMutationPlanStatus` (`domain/graph_plans.py`): `validated`, `invalid`, `conflict`,
`review_required`, `approved`, `committed`, `expired`, `abandoned`, `error`.
`TERMINAL_PLAN_STATUSES` blocks re-commit. Plans persist in
`adapters/outbound/graph/plan_stores/local_json.py` (`~/.potpie/graph_plans.json`, atomic
tmp-replace, keyed pot → plan_id).

### 5.2 Optimistic concurrency and receipts

A proposal records the pot's monotonic **revision** (`{"_global": <revision>}`); commit compares
it while applying and returns `conflict`, without applying the stale plan, if another canonical
write landed first. Every successful canonical mutation advances the revision, including
entity-only updates, invalidation, reset and snapshot import. There are no per-subgraph versions,
so unrelated writes to the same pot still conflict. The conflict result carries
`expected_version` / `actual_version` and a "reread and re-propose" recommendation.

| Backend | Compare-and-apply boundary |
|---|---|
| `in_memory` | one per-backend lock over revision check, mutation, receipt and increment; process-local by design |
| `embedded` | a process plus interprocess file lock over reload, check, mutation and atomic `graph.json` replace; revisions and receipts persist there |
| `neo4j` | one transaction owns the revision, the batch and a durable receipt |
| `falkordb`, `falkordb_lite` | Redis `WATCH`, then one `GRAPH.QUERY` inside `MULTI`/`EXEC` applies batch, revision and receipt |

- A completed mutation **receipt** is checked before the expected revision, so retrying a
  known-successful mutation returns its original result even after later writes. A receipt proves
  the write applied once, not that its values are still current; run `--verify` when that matters.
- An injected writer without `atomic_mutations_supported` falls back to a claim-content token and
  a process-local guard. That is not a cross-process or crash-atomic promise.
- An `idempotency_key` is bound to its request fingerprint: the same request returns the existing
  plan, different content is rejected.
- Corrections freeze their exact targets at propose (`retracted_claim_keys`), so apply never
  re-discovers claims by environment or other identity fields.
- Reset advances the revision and keeps receipts: retrying an old mutation after reset returns its
  historical receipt without recreating data. Use a new mutation id to restore on purpose.
- The CLI never resubmits a commit whose result is uncertain (for example a timeout). Commit
  recovery (`cli/commit_recovery.py`) polls the plan's durable receipt until it settles.
- Direct database edits that bypass the mutation port are outside this contract.

### 5.3 The diff shape

`GraphMutationDiff.to_dict()` emits exactly these keys (not the old `entities_created/…`):

```text
entity_upserts · edge_upserts · edge_deletes · invalidations ·
claims_asserted · claims_retracted · claim_keys
```

### 5.4 Bulk

`graph bulk apply` chunks an NDJSON/JSON stream of plans (`--chunk-size`, `--start-chunk`,
`--continue-on-error`, `--manifest`, `--idempotency-key`, `--verify`) over the same propose+commit
machinery for large baseline/ingestion writes. Full flags in [cli-flow.md](./cli-flow.md).

---

## 6. Spine B — direct mutate (record bridge only)

`DefaultGraphService.mutate(request)` (`application/services/graph_service.py`):
validates the request, then delegates preparation and execution to the shared
workbench owner. Dry-run returns a preview. Accepted writes persist a frozen
preparation, use the atomic request reservation and compare-and-apply contract,
and recover original receipts on retry. Review-required operations retain their
approval requirements. A missing atomic capability fails before writing. The
result retains the public semantic result shape; request/plan/execution identities
allow recoverable retries.

**What reaches Spine B today:**

- the **`record`** bridge (`application/services/record_to_semantic.py`), and
- `ingestion_submission_service` (the `context_record` deterministic path).

`record_to_semantic` maps each `record_type` to fixed semantic op(s): preference/policy →
`assert_claim POLICY_APPLIES_TO` (truth=preference, `decisions` subgraph); bug_pattern/fix →
REPRODUCES + RESOLVED/ATTEMPTED_FIX_FAILED (`debugging`); verification → VERIFIED;
decision → DECIDED (+AFFECTS, truth=user_decision); unknown types → free-form `RELATED_TO`. It
sets `allow_review_required=True, approved_by="context_record"` so a deliberate record write
(including medium-risk decisions) auto-applies; it never generates supersede/merge.
The bridge is exposed by the `potpie record` compatibility command.

> **`graph mutate` is a legacy wrapper, not Spine B.** The CLI `graph mutate`
> (`commands/graph.py`) internally calls workbench **propose → commit** and emits a legacy
> warning steering you to propose/commit. So `DefaultGraphService.mutate` is reached *only* via
> `record`/`context_record`, never via `graph mutate`.

---

## 7. The single backend write door

Direct semantic writes and planned commits share the workbench preparation and
execution owners. Raw/reconciled batches, invalidation, repair, supported reset,
snapshot import and journal changes enter their backend's atomic mutation owner.
Memory/JSON stage coherent state; FalkorDB uses native WATCH and a guarded command;
Neo4j uses a managed transaction. Graph changes, one revision transition and the
execution receipt share that boundary. The shared apply-plan writer orchestrates
canonical verbs where appropriate; it does not replace native atomicity.

Native relationships use `record_id` for physical identity. `claim_key` remains a
logical identity across reasserted incarnations. Shared lifecycle policy restricts
singleton supersession to the same source and environment, while corrections
carry exact reviewed incarnation targets and dependent effects. Occurrence,
validity and observation times remain distinct. Receipt replay precedes stale
revision checks and does not regenerate preparation.

---

## 8. Pre-apply validation / soft-fail downgrade

`validate_reconciliation_plan(batch, expected_pot_id)`
(`application/services/reconciliation_validation.py`) is the last gate before the writer:

- canonicalize the plan; enforce **hard caps** (5000 entities / 10000 edges / 2000 invalidations,
  duplicate-key detection, ISO temporal checks);
- optional canonical-label enrichment; backfill required properties.

With `CONTEXT_ENGINE_ONTOLOGY_SOFT_FAIL=1` (and not strict) it **downgrades instead of failing**:
drops unknown labels, maps an extractor `ADR` label onto the public `Document` entity and
falls back to `Observation` otherwise, coerces invalid lifecycle to `unknown`,
backfills missing edge temporal anchors with `now()`, rewrites unknown edge types → `RELATED_TO`
(confidence 0.3), and drops endpoint-mismatched edges. Each downgrade is recorded and may attach a
`QualityIssue` node. A final `validate_structural_mutations` + invalidation check raises
`MutationBatchValidationError` (structured issues) when the plan is still invalid. Material plans
with no provenance get a non-blocking evidence warning.

This is per-write **structural** integrity. It is distinct from the import-time ontology coherence
guards in §9.

---

## 9. Coherence invariants

`domain/coherence.py` keeps the unified ontology vocabularies aligned so the write DSL can never
drift from the catalogs it lowers against. `_run_import_time_checks()` runs at module load and
**fails startup fast**: identity labels ⊆ `ENTITY_TYPES`; every `RECORD_TYPES` `anchor_label` ⊆
`ENTITY_TYPES`; every `emits_predicate` ⊆ `EDGE_TYPES`; every `reader_include` advertised;
`STRUCTURAL_INCLUDES` disjoint from record includes; each `payload_schema` has a builder.
`assert_runtime_coherence(reader_backed_includes)` (called from bootstrap once readers exist)
asserts the live reader registry equals advertised `READER_BACKED_INCLUDES` and that event-playbook
prose names only canonical labels/predicates. Failures raise `OntologyCoherenceError` — the rule is
"align the declaration, don't relax the check." This is **ontology-vocabulary** coherence; the
catalogs themselves are owned by [ontology.md](./ontology.md).

---

## 10. Inbox

Inbox items are **pending graph work that intentionally never become facts** until a harness
processes them (read/search → propose → commit). `domain/graph_inbox.py` +
`adapters/outbound/graph/inbox_stores/local_json.py`; methods on `GraphWorkbenchService`
(`inbox_add/list/show/claim/mark_applied/mark_rejected/close`). States:
`pending → claimed → applied/rejected/closed` (`TERMINAL_INBOX_STATUSES`). `mark_applied` requires
a linked `plan_id` or `mutation_id`. Persisted in `~/.potpie/graph_inbox.json` keyed by pot. The
inbox store port is optional — absent wiring raises `CapabilityNotImplemented`.

CLI: `graph inbox add | list | show <id> | claim <id> | mark-applied <id> [--plan|--mutation] |
mark-rejected <id> [--reason] | close <id>`.

---

## 11. Quality scoring (diagnostic only)

Quality never writes — it surfaces findings and recommends propose/commit corrections or inbox
items. Two layers:

1. **Resolve-time** `assess_graph_quality(refs, coverage, fallbacks)` (`domain/graph_quality.py`)
   → a `GraphQualityReport` (`good/watch/degraded/unknown`) from source-reference freshness
   (TTL from the ontology fact-family), verification gaps, source-access gaps, and coverage.
   `detect_family_conflicts` finds contradicting live `RELATES_TO` edges per predicate-family +
   subject and classifies contradiction / supersession_pending / overlap (this feeds
   auto-supersede and conflict findings).
2. **Workbench read-only** `GraphWorkbenchService.quality(report=…)` scans `ClaimRow`s via
   `backend.claim_query` to emit `GraphQualityFinding`s for `summary | duplicate-candidates |
   stale-facts | conflicting-claims | orphan-entities | low-confidence | projection-drift |
   entity-label-drift`
   (status ok/watch/degraded). The same summary snapshot powers `commit --verify` regression
   detection (§5).

CLI: `graph quality <summary|duplicate-candidates|stale-facts|conflicting-claims|orphan-entities|
low-confidence|projection-drift|entity-label-drift> [--threshold 0.5] [--subgraph] [--limit]`.

---

## 12. Write commands (summary)

Full flags live in [cli-flow.md](./cli-flow.md); the write loop discipline is taught by the
`potpie-graph` skill (see [skills.md](./skills.md)).

| Command | Spine / role |
|---|---|
| `graph propose --file mutation.json [--ttl 1h]` | Spine A — validate + persist plan (no write) |
| `graph commit <plan_id> --verify [--approved-by]` | Spine A — apply persisted plan, read-back verify |
| `graph bulk apply --file <ndjson> [--chunk-size] [--verify]` | Spine A — chunked multi-plan apply |
| `graph mutate --file … [--dry-run] [--allow-review-required] [--approved-by]` | **legacy wrapper** over propose+commit |
| `graph mutation-template --kind <…>` | static schema-only skeleton (no host call) |
| `record --type … --summary …` | Spine B — record→semantic bridge |
| `graph history [--entity\|--claim\|--plan\|--mutation\|--subgraph]` | committed-write audit trail |
| `graph inbox …` / `graph quality …` | pending work / diagnostics (§10–11) |

Canonical loop: discover the contract (`graph catalog`) → read (`graph read`) → resolve identity
(`graph search-entities`) → `graph propose` → `graph commit --verify` → capture uncertainty
(`graph inbox add`) → check `graph quality`.

---

## See also

- [ontology.md](./ontology.md) — entities, predicates, truth classes, the 10 ops, identity keys.
- [querying.md](./querying.md) — the read trunk and the AgentEnvelope.
- [cli-flow.md](./cli-flow.md) — the full command/flag surface.
- [ingestion-nudge.md](./ingestion-nudge.md) — how raw episodes/events enter; the nudge model.
- [architecture.md](./architecture.md) — backends, the GraphWriterPort, the shared engine room.
- [skills.md](./skills.md) — the harness-taught propose/commit write discipline.

---

### Getting Started First Steps (`docs/getting-started/first-steps.md`)

---
title: First steps
description: Run the Potpie setup wizard, open your coding harness, and view the context graph.
---

## Prerequisites

Complete [Installation](./installation.md).

## Step 2: Run the Potpie setup wizard

```bash
potpie setup
```

The setup wizard provisions local config, storage, the daemon, a default pot, and
agent skills. It also lets you choose integrations and the coding harness Potpie
should configure.



## Step 3: Open your configured harness

Potpie is already integrated into your selected harness.
You can start using Potpie with the repo of your choice.

Open your previously selected harness and ask it to use Potpie for the repo.


> [!NOTE]
> You don't need to run a separate manual ingest command. The CLI registers
> sources and the configured agent can ingest or update project context when the
> task requires it.

You can view your context graph in the web UI:

```bash
potpie ui
```

This will open a graph explorer in your browser.

---

### Getting Started Installation (`docs/getting-started/installation.md`)

---
title: Installation
description: Install the Potpie CLI from PyPI with uv or pip.
---

## Step 1: install the CLI through [PyPI](https://pypi.org/project/potpie/) with `uv` or `pip`

```bash
uv tool install potpie
```

or:

```bash
python3 -m pip install --user potpie
```

> [!NOTE]
> `uv tool install potpie` is recommended for CLI installs because global
> mutation of Python packages is generally not recommended.

Continue with [First steps](./first-steps.md) to run the setup wizard.

---

### Guides Integrations (`docs/guides/integrations.md`)

---
title: Integrations and coding harnesses
description: Source integrations and coding harnesses that Potpie supports.
---

## Integrations and coding harnesses

Potpie supports a variety of integrations and coding harnesses, with more coming.
If your team needs a new integration or harness, please
[raise a ticket](https://github.com/potpie-ai/potpie/issues/new/choose).

### Integrations

| Tool | Description |
| --- | --- |
| ![GitHub][github-badge] | Index repositories, pull requests, issues, reviews and source history. |
| ![Linear][linear-badge] | Index teams, issues, projects and documents. |
| ![Jira][jira-badge] | Index projects, issues, status and changelog context. |
| ![Confluence][confluence-badge] | Index spaces, pages, runbooks and decisions. |

### Coding harnesses

| Tool | Description |
| --- | --- |
| ![Claude Code][claude-badge] | Install Potpie instructions and skills for Claude Code. |
| ![OpenAI Codex][codex-badge] | Install Potpie instructions and skills for OpenAI Codex. |
| ![Cursor][cursor-badge] | Install Potpie instructions and skills for Cursor. |
| ![OpenCode][opencode-badge] | Install Potpie skills for OpenCode. |

[github-badge]: https://img.shields.io/badge/GitHub-111827?style=flat-square&logo=github&logoColor=white
[linear-badge]: https://img.shields.io/badge/Linear-111827?style=flat-square&logo=linear&logoColor=white
[jira-badge]: https://img.shields.io/badge/Jira-111827?style=flat-square&logo=jira&logoColor=white
[confluence-badge]: https://img.shields.io/badge/Confluence-111827?style=flat-square&logo=confluence&logoColor=white
[claude-badge]: https://img.shields.io/badge/Claude%20Code-111827?style=flat-square&logo=anthropic&logoColor=white
[codex-badge]: https://img.shields.io/badge/OpenAI%20Codex-111827?style=flat-square&logo=openai&logoColor=white
[cursor-badge]: https://img.shields.io/badge/Cursor-111827?style=flat-square&logo=cursor&logoColor=white
[opencode-badge]: https://img.shields.io/badge/OpenCode-111827?style=flat-square&logo=opencode&logoColor=white

---

### Reference Cli (`docs/reference/cli.md`)

---
title: CLI reference
description: Main Potpie CLI commands and examples.
---

## Basic CLI user checklist

The main CLI commands are:

| Command | Purpose |
| --- | --- |
| `potpie setup` | Run first-time local setup for config, daemon, default pot, and agent skills. |
| `potpie login` | Sign in to Potpie for account-backed and managed features. |
| `potpie github login` / `potpie linear login` | Connect source integrations you want agents to use. |
| `potpie status` | Show context readiness for the active pot, including daemon, graph, and skill checks. |
| `potpie auth status` | Show configured integration auth status. |
| `potpie auth status --verify` | Verify integration credentials with lightweight API checks. |
| `potpie doctor` | Run local diagnostics for daemon, backend capabilities, and skill drift. |
| `potpie source add repo .` | Register the current repo as a source for the resolved pot. |
| `potpie pot list` / `potpie pot use {id-or-name}` | List pots and choose the active workspace. |
| `potpie resolve "{task}"` | Pull the context an agent should read before doing a task. |
| `potpie search "{query}"` | Look up a specific file, workflow, bug, decision, or convention. |
| `potpie record --type {type} --summary "{summary}"` | Write a durable project learning. |
| `potpie graph ...` | Use lower-level graph reads, quality checks, proposals, and commits. |
| `potpie ui` | Open the graph explorer served by the local daemon (`--pot managed:{id}` for a managed host). |
| `potpie host set {url} --token -` | Point the CLI at a managed context-graph host; the API key is read from stdin. |
| `potpie host use local` / `potpie host use managed` | Choose which host commands talk to by default. |
| `potpie host list` / `potpie host clear` | Show the configured hosts, or forget the managed one. |
| `potpie --host managed {command}` | Run one command against a host without changing the active one. |
| `potpie skills install --agent {agent}` | Install or refresh Potpie guidance for an agent harness. |

Examples:

```bash
potpie setup --repo . --agent claude
potpie github login
potpie auth status
potpie status
potpie source add repo .
potpie resolve "what should I know before working in this repository?"
potpie search "authentication flow"
potpie record --type decision --summary "Prefer the Potpie CLI for graph work"
```

### Managed hosts

A managed host serves the same commands from a hosted graph. Pot refs can name
their host (`managed:{id}`, `local:{id}`); a bare ref is looked up on the active
host only.

```bash
printf '%s\n' "$KEY" | potpie host set https://your-host.example --token -
potpie host use managed
potpie pot list --managed
potpie pot use managed:{id} --also-default-for-current-repo
potpie resolve "what changed in the payment flow?"
potpie --host local status
```

`potpie pot grant`, `revoke` and `grants` share a managed pot with other
people; the local host is single-user. Setup, skills, the daemon and
integration logins always stay on your machine.

You can find an exhaustive list with more examples in our [docs](https://docs.potpie.ai).

---

### Telemetry Sentry (`docs/telemetry/sentry.md`)

---
title: CLI Sentry telemetry
description: How the Potpie CLI reports unexpected failures to Sentry.
---

## Overview

Sentry is used only for unexpected Potpie CLI failures. The CLI-owned
integration lives under `potpie/cli`; engine-side metrics wiring remains under
`potpie/context-engine`.

## Configuration

The CLI root callback resolves product runtime settings through
`potpie.runtime.settings`. A project `.env` file is read only when the
bootstrap environment is `dev`; non-dev environments read the process
environment and packaged distribution defaults only. `.env` values fill missing
process env keys and cannot set or change `POTPIE_ENVIRONMENT`.

Environment precedence:

- process environment
- `.env` values, only when `POTPIE_ENVIRONMENT` resolves to `dev` and only for
  missing keys
- distribution defaults packaged into the wheel
- code defaults

Canonical Sentry configuration:

- `POTPIE_ENVIRONMENT`: Sentry environment and telemetry event environment.
- `POTPIE_SENTRY_DSN`: enables Sentry when present.
- `POTPIE_TELEMETRY_DISABLED=1`: disables all outbound telemetry.
- `POTPIE_SENTRY_ENABLED=0`: disables Sentry only.
- `POTPIE_SENTRY_RELEASE`: optional release override; otherwise
  `potpie-cli@<potpie-context-engine version>`.
- `POTPIE_SENTRY_DIST`: optional dist override; otherwise the generated build
  Git SHA when available.

Generic `SENTRY_*` aliases are not read. Distribution defaults are packaged
public defaults for installed wheels, not production environment variables or
secrets.

Sentry initializes directly through `sentry-sdk` with:

- `send_default_pii=False`
- `include_local_variables=False`
- `max_request_body_size="never"`
- `before_send` event scrubbing
- `before_breadcrumb` breadcrumb scrubbing

The CLI never calls `sentry_sdk.set_user()`.

## Delivery

A CLI command never waits on the network for telemetry. It appends its
metrics and analytics events to a local spool:

```text
$XDG_CONFIG_HOME/potpie/telemetry/spool.jsonl
~/.config/potpie/telemetry/spool.jsonl
```

When the command exits, it starts a detached flusher
(`python -m potpie.cli.telemetry.flush`) that sends the spool and then exits.
The daemon starts the flusher right after it spools an event. Only one flusher
runs at a time. The spool file has `0600` permissions and never holds a DSN or
an API key. It is capped at 512 KiB, and a failed send drops its records
instead of retrying them.

The CLI process initializes `sentry-sdk` only to report an unexpected failure.
The flusher initializes it to send metrics. Both use a short-lived profile: no
auto-enabling integrations and a one-second shutdown timeout. The daemon keeps
the SDK defaults.

When telemetry is disabled, by preference or with
`POTPIE_TELEMETRY_DISABLED=1`, nothing is spooled. The flusher also checks the
settings again and drops anything already spooled. `potpie telemetry disable`
deletes the pending spool.

## Identity State

Reusable non-secret telemetry identity is stored globally:

```text
$XDG_CONFIG_HOME/potpie/telemetry/identity.json
~/.config/potpie/telemetry/identity.json
```

The file is written with `0600` permissions through an atomic temp-file replace.
It contains:

- `schema_version`
- `anonymous_install_id`
- `created_at`
- `last_seen_at`

The identity file never stores DSNs, auth tokens, API keys, user IDs, org IDs,
repo names, prompts, code, paths, headers, or request bodies. The
`anonymous_install_id` is stable until an explicit reset flow exists. Each CLI
run also gets an in-memory `invocation_id`; each process gets an in-memory
`daemon_session_id`.

## Captured Errors

Captured:

- unexpected exceptions crossing `commands/_common.py::contract()`
- unexpected auth implementation failures in login/logout flows that do not use
  `contract()`
- unexpected daemon command failures through the normal CLI command boundary

Not captured:

- `typer.Exit`
- `KeyboardInterrupt`, EOF, or user cancellation
- validation errors
- expected domain errors such as `pot_not_found`, `no_active_pot`,
  `context_engine_unavailable`, and `not_implemented`
- auth denied, expired, or missing credentials
- missing local config
- missing DSN or disabled telemetry

Captured events use stable metadata only:

- `service`
- `command`
- `subcommand` when available
- `output_mode`
- `cli_version`
- `python_version`
- `os`
- `arch`
- `error.code`
- `error.kind`
- `is_expected=false`

Allowed event context:

- `anonymous_install_id`
- `invocation_id`
- `daemon_session_id`

## Privacy Scrubbing

Telemetry code avoids attaching sensitive data in the first place. The generic
scrubber is owned alongside the Context Engine metrics adapter and re-exported
by the CLI telemetry package; Sentry SDK privacy settings and hooks are the
final guard before transport.

`before_send` removes:

- request data
- headers
- cookies
- env
- extra payloads
- module lists
- server name
- stack frame locals
- full frame paths
- raw exception messages and values

`before_breadcrumb` drops HTTP/subprocess breadcrumbs and strips breadcrumb
`data` and `message` from kept breadcrumbs.

Sentry events must not contain:

- email, name, user ID, or org ID
- prompts or episode bodies
- source code or file contents
- terminal output
- repository names
- git remotes
- full local paths
- URLs with query strings
- request or response bodies
- headers
- environment variables
- API keys, GitHub tokens, bearer tokens, or secrets
- frame locals
- raw exception messages or values
- raw CLI command arguments

## Local Verification

Default tests use fake or in-memory Sentry surfaces and do not call the network.

```bash
UV_CACHE_DIR=/private/tmp/uv-cache uv run pytest tests/unit/test_sentry_*.py tests/unit/test_telemetry_*.py -q
UV_CACHE_DIR=/private/tmp/uv-cache uv run --package potpie-context-engine pytest potpie/context-engine/tests/unit/test_sentry_daemon.py potpie/context-engine/tests/unit/test_sentry_metrics_*.py potpie/context-engine/tests/unit/test_telemetry_port.py -q
UV_CACHE_DIR=/private/tmp/uv-cache uv run ruff check potpie/cli tests/unit/test_sentry_*.py tests/unit/test_telemetry_*.py
```

Manual CLI smoke:

```bash
XDG_CONFIG_HOME=/tmp/potpie-xdg \
UV_CACHE_DIR=/private/tmp/uv-cache \
uv run potpie --json daemon status
```

Expected result: the command prints daemon status JSON (exit `2` with
`daemon_unavailable` when no daemon is running, `0` when one is serving) and creates
`/tmp/potpie-xdg/potpie/telemetry/identity.json` without requiring a Sentry DSN.

---

### Tests Docs Package.Json (`tests/docs/package.json`)

{
  "name": "potpie-docs-check",
  "private": true,
  "type": "module",
  "description": "Spoke-owned docs check. Hub fetch-spokes remains the publish gate.",
  "scripts": {
    "check": "node docs-check.mjs",
    "test": "node --test"
  },
  "engines": {
    "node": ">=22"
  },
  "dependencies": {
    "@xmldom/xmldom": "^0.9.12",
    "yaml": "^2.8.2"
  }
}

---

--- METRICS ---
- Files Extracted: 30
- Estimated Token Budget: ~98468 tokens
- Canonical Reference: https://codewiki.google/github.com/potpie-ai/potpie
