# Contribute

> Nix or Make build, make test through Dagger, OpenAPI CONTRACT seals, sqlc generate, PR title labels, and tapes vs tapesctl ownership.

- Repository: papercomputeco/tapes
- GitHub: https://github.com/papercomputeco/tapes
- Human docs: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750
- Complete Markdown: https://grok-wiki.com/public/docs/papercomputeco-tapes-020596f21750/llms-full.txt

## Source Files

- `CONTRIBUTING.md`
- `makefile`
- `flake.nix`
- `docs/development.md`
- `cmd/tapes/dev/dev.go`
- `cmd/tapes/dev/checkopenapi.go`
- `AGENTS.md`

---

---
title: "Contribute"
description: "Nix or Make build, make test through Dagger, OpenAPI CONTRACT seals, sqlc generate, PR title labels, and tapes vs tapesctl ownership."
---

This repository is the **server**: `tapes` owns the database, the services, and the operator CLI. Development is Make-first (`make help`); Dagger provisions the pinned Postgres image and runs the checks. The Nix flake is the recommended toolchain. Client commands live in [`tapesctl`](https://github.com/papercomputeco/tapesctl) — do not re-add them here.

## Prerequisites

| Requirement | Used for |
| --- | --- |
| Go 1.26+ (`go 1.26.1` in `go.mod`) | Local `make build-local` |
| Docker | `make format`, `make check`, `make test`, and every other Dagger target |
| Nix flakes (recommended) | Pinned Go 1.26, Dagger, `sqlc`, `hurl`, `GOEXPERIMENT=jsonv2` |
| PostgreSQL with pgvector + pg_duckdb | Local `tapes serve` / `tapes local up` |
| Ollama (optional) | Default local embeddings |

Merkle hashing requires `GOEXPERIMENT=jsonv2`. `make build-local` and the Nix shell set it. Building without it fails.

<Warning>
Do not point tests at an arbitrary stock Postgres container. Missing pgvector / pg_duckdb looks like application failure. `make test` binds `public.ecr.aws/g4e5l3z3/papercomputeco/postgres:17.7-pgduckdb-1.1.1` and injects `TEST_POSTGRES_DSN`.
</Warning>

## Dev environment

<Tabs>
<Tab title="Nix (recommended)">

```bash
git clone https://github.com/papercomputeco/tapes.git
cd tapes
nix develop          # or: direnv allow  (.envrc is `use flake . --impure`)
make help
```

The flake `devShells.default` installs `go_1_26`, `gotools`, `sqlc`, `gnumake`, Dagger, `git`, and `hurl`, and exports `GOEXPERIMENT=jsonv2`. The shell hook prints `make help`.

</Tab>
<Tab title="Manual">

Install Go 1.26+ and Docker yourself, then:

```bash
make build-local
./build/tapes local up
```

`make format` / `make check` / `make test` still need Docker.

</Tab>
</Tabs>

## Build and run

<Steps>
<Step title="Build the local binary">

```bash
make build-local
```

This is `CGO_ENABLED=0 GOEXPERIMENT=jsonv2 go build` of `./cli/tapes` into `./build/tapes`. ldflags stamp `pkg/utils.Version`, `Sha`, `Buildtime` from `git describe` / `git rev-parse HEAD` / UTC time.

Install into `$(go env GOBIN)` with `make install`. That target uses `install -m 0755` (temp file + rename), not `cp`: overwriting the binary in place invalidates the running Mach-O code signature on macOS and SIGKILLs the next invocation.

</Step>
<Step title="Start local dependencies and the stack">

```bash
./build/tapes local up
./build/tapes serve
```

`tapes local` is a group (`up` / `status` / `down`), not a no-arg start. `tapes local up` writes Postgres / pgvector / Ollama settings into the active `.tapes/config.toml`.

</Step>
<Step title="Seed and list through the client">

Seeding is a **client** operation:

```bash
tapesctl seed --tapes-url http://localhost:8081
tapesctl sessions list --tapes-url http://localhost:8081
```

Reset demo data with a fresh database behind the API (`tapes local down --wipe && tapes local up`), not by re-running seed against the same DB as if it were idempotent cleanup.

</Step>
</Steps>

<CodeGroup>

```bash title="Local build"
make build-local
./build/tapes version
```

```bash title="Cross-platform release artifacts"
make build          # dagger call build-release … export --path ./build
```

```bash title="Format (applies fixes)"
make format         # dagger call fix-lint export --path .
```

</CodeGroup>

`make clean` removes `./build`. Default goal is `make help`.

## Tests and Dagger checks

Always go through Make. `make test` is `dagger call test`: a Go 1.26-bookworm container (`CGO_ENABLED=0`, `GOEXPERIMENT=jsonv2`) with the pinned Postgres service bound as `postgres` and `TEST_POSTGRES_DSN=host=postgres user=tapes password=tapes dbname=tapes port=5432 sslmode=disable`. The suite is `go test -count=1 -v ./...`.

`make test-run-id` passes `--run-id="$(date +%s)"` to bypass Dagger's exec cache.

| Target | Dagger function | What it does |
| --- | --- | --- |
| `make test` | `test` | Full `go test ./...` with Postgres |
| `make check` | `dagger check` | Every `+check` function (see below) |
| `make format` | `fix-lint` | golangci-lint v2.8.0 with `--fix` |
| `make parity` | `check-parity` | Envelope corpus / parser oracle (`./pkg/backfill/...`, `./pkg/sessions/...`), no services |
| `make test-extproc` | `check-extproc` | `./extproc/...` and `./cli/extproc-replay-server/...`, no services |
| `make check-extproc-image` | `build-extproc-image` + `sync` | Image builds (CI gate); does not load |
| `make e2e-test` | `test-e-2-e` | Proxy / ingest / derive / span search with Postgres + Ollama |
| `make contracts` | `contracts` | Writes documented YAML to `./build/contracts` |
| `make generate` | *(local `sqlc`, not Dagger)* | Regenerates `pkg/storage/postgres/gensqlc` |

`dagger check` (`make check`) currently includes:

- `CheckGenerate` — regenerate sqlc @ v1.30.0 and `diff` against committed `gensqlc`
- `Test` — full suite + Postgres
- `CheckGoModTidy` — `go mod tidy` must be a no-op
- `CheckLint` — golangci-lint, no auto-fix
- `CheckExtproc` — extproc adapter tests
- `CheckParity` — envelope contract fixtures

<Note>
GitHub `ci.yaml` does **not** run `make test` / the full DB-backed suite on every PR. PR CI runs `make parity`, `make test-extproc`, `make check-extproc-image`, `make e2e-test`, `dagger call build`, and a `tapes version` smoke matrix. Run `make test` and `make check` locally before opening the PR.
</Note>

Tests use **Ginkgo / Gomega**. Do not introduce a second test framework.

## OpenAPI CONTRACT seals

There is no swaggo and no checked-in OpenAPI file. Routes register through the `oasfiber` wrapper (`api/openapi_routes.go`, `ingest/openapi.go`). Each running server compiles its contract and serves it at `GET /openapi`. Field prose comes from ordinary Go doc comments, not struct tags.

A deployed binary has no source tree, so `GET /openapi` has route/operation prose but not per-field prose. From a checkout:

```bash
tapes dev openapi                          # read API, YAML, prose from .
tapes dev openapi ingest                   # ingest write surface
tapes dev openapi --format json
tapes dev openapi --docs-root ''           # shapes only — what the seal covers
tapes dev openapi --out api-contract.yaml
```

<ParamField body="docs-root" type="string" default=".">
Module root to load doc comments from. Empty string skips prose (`gosource` is not walked). A bad path fails rather than silently emitting the stripped document.
</ParamField>

<ParamField body="format" type="string" default="yaml">
`yaml` or `json`.
</ParamField>

<ParamField body="out" type="string">
Write to a file (mode `0600`) instead of stdout. Stderr prints `openapi` version, path count, and fingerprint.
</ParamField>

Surfaces: `api` (default) and `ingest`. Nothing in this repo reads the compiled file. `make contracts` writes `build/contracts/tapes-api.yaml` and `tapes-ingest.yaml` **with** field prose (`--docs-root .`). Those bytes are for consumers; they are not checked in.

### What the seal is

`api/CONTRACT` and `ingest/CONTRACT` each hold one `sha256:…` fingerprint of the **prose-stripped** compiled JSON — the same document as `tapes dev openapi <surface> --docs-root ''` and the same document a released binary serves. Comment lines (`#`) and blanks are ignored; exactly one `sha256:` line is required.

`api/openapi_seal_test.go` and `ingest/openapi_seal_test.go` recompile with `nil` TypeDocs and compare. A move fails until you paste the printed fingerprint into the `CONTRACT` file **in the same change**.

| Change | Moves the seal? |
| --- | --- |
| Route, parameter, schema, status code, field name | Yes |
| `Summary` / `Description` declared inline on a registration | Yes |
| Go doc-comment edit (field prose) | No |
| Cassette mounted at runtime on `:8081` | No — the seal is the **core** document. The served `/openapi` can grow as cassettes come and go. |

Bump the seal in the same PR that moved the contract. A follow-up bump is a change nobody was asked to review.

To see what moved: `make contracts`, then diff the YAML. Do **not** paste a `make contracts` / default `tapes dev openapi` fingerprint into `CONTRACT` — that is the documented document, and it would make every comment edit a contract event.

`tapes dev check-openapi` is a different gate: it validates composite `GET /v1/sessions/{id}/traces` fixtures against the compiled `SessionTracesResponse` schema (types of fields present). Structural completeness is `tapes dev check-invariants`.

## sqlc generate

<ParamField body="sqlc.yaml" type="file" required>
PostgreSQL engine. Schema: `migrations/`. Queries: `pkg/storage/postgres/queries/{derive,derive_queue,raw_turns,sessions,skills,spans}.sql`. Output package: `gensqlc` → `pkg/storage/postgres/gensqlc` (`pgx/v5`).
</ParamField>

```bash
make generate          # sqlc generate  (Nix pins sqlc; CI CheckGenerate uses sqlc@v1.30.0)
```

`pkg/storage/postgres/gensqlc` is generated (`sqlc v1.30.0`). Do not edit it. After changing a query file or a migration that sqlc reads, run `make generate` and commit the diff. `CheckGenerate` copies `gensqlc`, regenerates, and `diff`s — drift fails `make check`.

## tapes vs tapesctl ownership

```mermaid
flowchart LR
  subgraph thisRepo["papercomputeco/tapes"]
    tapesBin["tapes: init, local, serve, status, auth, config, backfill, raw equivalence, dev, version"]
    services["proxy :8080 · read API :8081 · ingest :8082 · derive-worker · embed-worker"]
    extproc["tapes-extproc image — Envoy path, no DB dependency"]
    db["PostgreSQL — owned by this binary"]
    tapesBin --> services
    services --> db
  end
  subgraph clientRepo["papercomputeco/tapesctl"]
    ctl["tapesctl: start, capture, export, seed, sessions list, search, skill"]
  end
  ctl -->|"read :8081"| services
  ctl -->|"write :8082"| services
  extproc -->|"POST /v1/ingest"| services
```

| Stays in `tapes` | Moved to `tapesctl` — do not re-add |
| --- | --- |
| `init`, `local`, `serve`, `status`, `auth`, `config`, `backfill`, `raw equivalence`, `dev`, `version` | `tapes start` → `tapesctl start` |
| Span search **implementation** (`GET /v1/search/spans`, embed worker) | `tapes export` → `tapesctl export` |
| Database, migrations, derive, ingest | `tapes seed` → `tapesctl seed` |
| | `tapes sessions` → `tapesctl sessions list` |
| | `tapes search` → `tapesctl search` (client only) |
| | `tapes skill` → `tapesctl skill` |

`tapes chat` is gone. The deck TUI is gone; there is no CLI replacement. Search and skill **authoring** stay server-side for now.

`tapes-extproc` ships from this repo (`Dockerfile.extproc`, `cli/tapes-extproc`) as its own image, not as `tapes serve …`. It must not depend on the API, database, or derive worker being healthy. It shares `pkg/capture` reducers and ingest limits on purpose: two capture paths that reduce differently is the failure it exists to avoid.

## Documentation in the same PR

Every PR that changes user-visible behavior **must** update `docs/` in the same PR. The docs are flat Markdown, no build step: one page per file.

A new page needs all three:

- `title`
- `description` (one sentence)
- `sidebar.order`

Links are relative (`./cli.md`) so they resolve in the checkout and on the published site.

Do not write design documents or implementation plans to disk (`docs/plans/` or similar). Discuss plans in conversation only.

## PR title and CI labels

Search for an existing issue first. CI rejects any PR title that does not start with one of these. Merges are squashed: the **PR title is the commit message on `main`**.

| Prefix | Meaning | Semver |
| --- | --- | --- |
| `✨ feat:` / `:sparkles: feat:` | New feature | Minor (`0.1.0` → `0.2.0`) |
| `🔧 fix:` / `:wrench: fix:` | Bug fix | Patch |
| `🧹 chore:` / `:broom: chore:` | CI, tests, non-feature/non-bug | Patch |
| `♻️ refactor:` / `:recycle: refactor:` | Behaviour-preserving restructure | Patch |
| `🎨 design:` / `:art: design:` | Design / presentation | — |
| `📚 docs:` / `:books: docs:` | Documentation only | — |
| `✏️ RFD:` / `:pencil2: RFD:` | Request for discussion | — |

Optional scope in parentheses: `✨ feat(ingest): …`. Breaking change: `!` before the colon (`✨ feat!:`, `🔧 fix(api)!:`) — major bump.

The validator is `ghcontrib` from `papercomputeco/daggerverse` (`.github/workflows/pr.yaml`). If this table and `validPRTitleSpecs` ever disagree, the module wins.

A second job, `check-pull-request-linear-magic-word`, runs from the same module. The exact token is not defined in this repository.

## Constraints that break the product if ignored

- **`Bucket`** (`pkg/merkle.Bucket`) is the content-addressing unit. Changing it changes the in-memory merkle / dedup layer (`merkle.ProjectContent`) for every derive. The persisted `nodes` table is gone; merkle is not a user-facing browse surface.
- Prefer `func NewExampleStruct() *ExampleStruct`.
- Language: Go 1.26+. Module: `github.com/papercomputeco/tapes`.

:::files
tapes/
├── makefile                 # all supported build / test / format entry points
├── flake.nix / .envrc       # recommended toolchain + GOEXPERIMENT=jsonv2
├── sqlc.yaml                # query → gensqlc mapping
├── api/CONTRACT             # sha256 seal of prose-stripped read contract
├── ingest/CONTRACT          # sha256 seal of prose-stripped ingest contract
├── api/openapi_routes.go    # read-surface registrations
├── ingest/openapi.go        # write-surface registrations
├── pkg/storage/postgres/queries/   # sqlc inputs
├── pkg/storage/postgres/gensqlc/   # generated — do not hand-edit
├── migrations/              # sqlc schema + runtime migrations
├── cmd/tapes/dev/           # openapi, check-openapi, check-invariants, fixtures
├── .dagger/                 # Test, CheckGenerate, lint, parity, extproc, e2e
├── docs/                    # user-visible docs, same PR as the change
└── extproc/                 # Envoy adapter; separate image
:::

## `tapes dev` (checkout-only utilities)

| Command | Needs DB? | Role |
| --- | --- | --- |
| `tapes dev openapi [api\|ingest]` | No | Compile contract; fold field prose from the tree |
| `tapes dev trace-fixtures` | No | Replay corpus raw layers through the real deriver + API renderers |
| `tapes dev check-invariants` | No | Structural properties of composite session-traces JSON |
| `tapes dev check-openapi` | No | Types of present fields vs `SessionTracesResponse` |
| `tapes dev dump-corpus` | Yes | Inverse of fixture replay: export `raw_turns` to corpus files |
| `tapes dev rederive` | Yes | Same whole-project rebuild as `POST /v1/admin/derive/run`, as a direct DB call |
| `tapes dev embed-spans` | Yes | Backfill span embeddings |

## Common failures

| Symptom | Cause | Fix |
| --- | --- | --- |
| Build / merkle panic or jsonv2 errors | `GOEXPERIMENT` unset | Use Nix or `make build-local` |
| `make test` / `format` / `check` fail immediately | No Docker | Start Docker; Dagger needs it |
| DB tests fail on extensions / types | Stock Postgres | Use `make test`, not a hand-rolled container |
| `CheckGenerate` red | Hand-edited `gensqlc` or skipped generate | `make generate`, commit the diff |
| `openapi_seal_test` red | Route / inline prose / schema moved | Write the printed `sha256:` into `api/CONTRACT` or `ingest/CONTRACT` in **this** PR |
| Seal bumped but comments-only change | Pasted documented fingerprint | Recompile with `--docs-root ''` |
| PR title check red | Missing emoji conventional prefix | Match the table above |
| Next `tapes` invocation SIGKILL on macOS | Binary overwritten in place | `make install` |

## Next

<CardGroup>
<Card title="CLI reference" href="/cli-reference">
`tapes` commands and flags. `tapesctl` is the client, not this binary.
</Card>
<Card title="Read API" href="/read-api">
Compiled `GET /openapi` on `:8081` and CONTRACT seal rules.
</Card>
<Card title="Ingest API" href="/ingest-api">
Private write contract on `:8082`. A seal miss here breaks every capture adapter.
</Card>
<Card title="Read API vs ingest" href="/read-vs-ingest">
Two servers, two ports, two trust models.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
`GOEXPERIMENT=jsonv2` build failures and other operator pitfalls.
</Card>
<Card title="Gateway capture" href="/gateway-capture">
`tapes-extproc` lives in this repo; keep its reducers aligned with ingest.
</Card>
</CardGroup>
