Rebuild the shell, add Calendar and Learn, and govern reads
Seven parallel agents and an adversarial verification pass. The three things worth knowing before reading the diff: RBAC WAS ALREADY BUILT. docs/build-plan.md marks F2 and F3 outstanding and is stale — packages/core/src/permissions.ts and lib/mutation.ts shipped long ago. So this does not rebuild them; it closes the gaps an audit found. The big one is that reads were entirely ungoverned: every GET was "any authenticated member", so a junior demand rep and a research contractor could both pull per-block supplier cost and break-even prices from /api/capacity/margin, and every contract's negotiated terms. For a company whose margin is the business, that was the hole that mattered. Adds book:read / economics:read / team:read, a readGuard middleware, and a `viewer` role below member. THE BUTTON AND THE 403 DISAGREED — the exact thing F3 said must never happen. Contracts.tsx never called can() at all, so its save button was always enabled against a server requiring contract:sign; Capacity.tsx gated commitment creation on deal:write/demand while the server wanted commitment:write/supply. POST /api/activities was the one write bypassing executeMutation: no capability check, and any member could mutate accounts.lastActivityAt as a side effect. It is now a proper mutation() behind activity:write. The shell becomes three panes — a collapsible shadcn sidebar with an account switcher on the Piggy accent, a header with real search, and Piggy docked to the right, page-aware and persistent across navigation. The phone keeps its bottom tab bar, which is the thing this product already beat trycompai/crm on, and gains the sidebar as a sheet. Calendar is a projection over thirteen dated sources rather than a new table, because a table would duplicate dates that already live on contracts, deals and commitments and would drift — and one ledger answering the question is the whole argument. It surfaces export_authorizations and compliance_artifacts, which had indexed expires_at columns, schema comments saying they must be alerted on, and no read endpoint or UI anywhere. Learn carries two tracks. Concepts are members-only; the platform track can be opened with a share code by someone with no account. The code mints a scoped learn-only token and never a Principal — every route here resolves a principal and then checks capabilities, so a principal-minting code would be one missing check away from leaking the book. "Only platform-track rows may be code-visible" is a database CHECK constraint as well as a write-path rule, and a test asserts a valid learn token still gets 401 on /api/dashboard, /api/accounts and /api/contracts — the same invariant scripts/deploy.sh refuses to ship without. CD becomes tag-to-ship. CI publishes an image to the Gitea registry on a release-* tag and cloud-2 pulls it, so no credential on the shared runner can execute anything on production — by construction rather than by policy. Both halves of deploy.sh's original rule survive: nothing on the runner reaches the host, and a human still decides when it ships. deploy.sh gains a rollback and a public-origin check, and PIG_IMAGE now reaches compose through `sudo env`, without which sudo's env_reset silently resolved every release to pig:local. Tests 141 -> 261. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
|
||||
[](./LICENSE)
|
||||
|
||||
*Self-hostable. Auditable. Built for teams that buy compute on one side and sell it on the other.*
|
||||
*Self-hostable. Auditable. Built for teams that buy GPU capacity on one side and sell it on the other.*
|
||||
|
||||
</div>
|
||||
|
||||
@@ -17,167 +17,490 @@
|
||||
A company that aggregates GPU capacity and resells it does not run one pipeline.
|
||||
It runs two, and its business is the spread between them.
|
||||
|
||||
Generic CRMs — Salesforce, HubSpot, Attio — model a single pipeline of deals
|
||||
against companies. They have no concept of **inventory**, no concept of a
|
||||
**commitment you already bought and are paying for**, and therefore no way to
|
||||
answer the question the business actually turns on:
|
||||
Today that spread is usually managed in a spreadsheet with a margin calculator
|
||||
in column K, a document of supplier terms, and a general-purpose CRM that has
|
||||
no idea what an H100-hour is. Salesforce, HubSpot and Attio model a single
|
||||
pipeline of deals against companies. They have no concept of **inventory**, no
|
||||
concept of a **commitment you already bought and are paying for whether or not
|
||||
it sells**, and therefore no way to answer the question the business turns on:
|
||||
|
||||
> Which contracted capacity is sold, to whom, at what margin — and what is idle
|
||||
> right now?
|
||||
|
||||
PIG is built around that question. One table, [`allocations`](./packages/db/src/schema/allocations.ts),
|
||||
joins a `capacity_commitment` (what you bought from a provider) to a
|
||||
`demand_deal` (what you sold to a customer). Revenue minus cost is margin per
|
||||
GPU-hour. Committed capacity with no allocation is money burning. Everything
|
||||
else in PIG is ordinary CRM plumbing that exists to keep that ledger honest.
|
||||
PIG is one ledger that knows the domain. The load-bearing table is
|
||||
[`allocations`](./packages/db/src/schema/allocations.ts), which joins a
|
||||
`capacity_commitment` (what you bought, at a known cost) to a `demand_deal`
|
||||
(what you sold, at a known price). Margin, utilisation and idle capacity all
|
||||
fall out of that one join. Everything else is plumbing that keeps the ledger
|
||||
honest.
|
||||
|
||||
## Who it's for
|
||||
**Cost is charged against the full commitment, not only the hours that sold.**
|
||||
Unsold hours are already paid for. Charging only the allocated share reports a
|
||||
healthy margin on a block that is losing money, which is precisely the failure
|
||||
PIG exists to prevent. There is a test pinning it.
|
||||
|
||||
## Who it is for
|
||||
|
||||
PIG models three teams, because two-sided compute companies have three
|
||||
constituencies competing for the same scarce capacity:
|
||||
constituencies competing for the same scarce capacity.
|
||||
|
||||
| Team | Job to be done |
|
||||
|---|---|
|
||||
| **Supply** | Source, qualify, price, and contract GPU capacity from providers |
|
||||
| **Supply** | Source, qualify, price and contract GPU capacity from providers |
|
||||
| **Demand** | Sell compute and post-training; renew and expand accounts |
|
||||
| **Research** | Consume capacity internally — real burn, no revenue |
|
||||
|
||||
Research is a first-class tenant rather than an afterthought. Internal research
|
||||
burn competes with revenue for the same GPUs, and margin math that cannot see it
|
||||
is wrong.
|
||||
Research is a first-class tenant rather than an afterthought: internal burn
|
||||
competes with revenue for the same GPUs, and margin arithmetic that cannot see
|
||||
it is wrong.
|
||||
|
||||
The team set is configurable. PIG ships with these three because they match the
|
||||
structure of the company it was designed for, not because they are universal.
|
||||
The team set is configurable in `packages/core/src/ontology.ts`. PIG ships with
|
||||
these three because they match the structure of the company it was designed
|
||||
for, not because they are universal.
|
||||
|
||||
## Agent-native, not agent-decorated
|
||||
## Screenshots
|
||||
|
||||
PIG is a first-class application for agents *and* for humans, and neither is a
|
||||
degraded view of the other.
|
||||
> **Placeholder — fresh captures needed.** The application shell was rebuilt as
|
||||
> a three-pane layout (header, collapsible sidebar rail, docked Piggy) and
|
||||
> every screenshot taken before that redesign now misrepresents the product.
|
||||
> Rather than ship misleading images, this section is deliberately empty until
|
||||
> the new shell is re-shot at desktop and at 393px, in light and dark.
|
||||
>
|
||||
> Pages to capture: `/` Overview, `/margin`, `/capacity` (the matcher),
|
||||
> `/calendar`, `/piggy` docked beside a record.
|
||||
|
||||
- **An MCP server** ([`apps/mcp`](./apps/mcp)) exposes the CRM over both stdio
|
||||
and Streamable HTTP. Any MCP client connects: **Claude Code**, **Codex**,
|
||||
**[prime-agent](https://github.com/PrimeIntellect-ai/prime-agent)**, or a
|
||||
**[Buzz](https://github.com/block/buzz)** workspace agent via its ACP bridge.
|
||||
Each team member points their own agent at PIG and works from the terminal.
|
||||
- **Piggy**, the in-app agent, drains a leased database queue rather than being
|
||||
called over HTTP — so work survives the agent being down, and every action it
|
||||
takes is recorded with an idempotency key.
|
||||
- **Every agent-derived fact carries evidence.** Enrichment writes to a `facts`
|
||||
table with a confidence score, a band (verified / probable / possible), a
|
||||
source URL, and a status. Strong signals apply automatically; weak ones become
|
||||
proposals a human approves. A CRM that lets an agent write unattributed claims
|
||||
into the record is a hallucination store, not a database.
|
||||
<!-- TODO(screenshots): add docs/screenshots/{overview,margin,capacity-match,calendar}.png
|
||||
— post-redesign, light and dark, desktop and 393px. Do not reuse the
|
||||
pre-redesign pig-*.png audit captures. -->
|
||||
|
||||
### The architectural rule
|
||||
## The two-sided data model
|
||||
|
||||
> **Intelligence never lives in the API.**
|
||||
Forty-seven tables, but the shape is small. These are the ones that carry the
|
||||
thesis:
|
||||
|
||||
The API does HTTP, auth, validation, and sync. All research, enrichment,
|
||||
scoring, and identity matching lives in the agent. They communicate through a
|
||||
table, never a direct call. This separation is borrowed from
|
||||
[Comp AI CRM](https://github.com/trycompai/crm) and it is the single most
|
||||
load-bearing decision in the codebase.
|
||||
| Table | What it holds | Why it is not in a generic CRM |
|
||||
|---|---|---|
|
||||
| `capacity_commitments` | What you bought: term, GPU-hours, cost per GPU-hour, floor and ceiling, and a **shape** (`{intervals[], quantities[]}`) | Real contracts ramp across tranches and step down at checkpoints; a single start/end/total reports availability that does not exist in the month someone wants it |
|
||||
| `demand_deals` | What you are selling: ACV, product line, MSA/DPA state, stage | The paper state is a separate axis from the stage, because paper gates delivery |
|
||||
| `supply_deals` | The other pipeline: sourcing a provider through diligence to live | Generic CRMs have one pipeline and call the supplier a vendor |
|
||||
| **`allocations`** | **The join.** Commitment × deal × GPU-hours × window × status | This is the whole product. Margin, utilisation and idle all derive from it |
|
||||
| `inventory_listings` | Market availability mirrored from the Prime Intellect API | Sync is a straight field mapping, not an ETL project |
|
||||
| `capacity_requests` | What a customer asked for, whether or not it could be served | Unservable demand is the signal for what to buy next |
|
||||
| `contracts` + `sla_terms` + `sla_metric_targets` + `contract_obligations` | Polymorphic over party and type — MSA, DPA, SLA, order form, capacity commitment — with negotiated SLA terms and dated obligations | The supply side negotiates heavyweight paper; the self-serve demand side runs on a reliability tier and a credits policy instead |
|
||||
| `export_authorizations`, `compliance_artifacts`, `compliance_decisions` | Export-control determinations recorded **on the allocation edge**, with reasoning and rule version | US controls apply an ultimate-parent test that reaches through the corporate tree, so country of incorporation is not a valid key |
|
||||
| `facts` | Every agent-derived claim, with score, band, evidence excerpt and source URL | An agent allowed to write unattributed claims will eventually write a wrong one and nobody will be able to tell which |
|
||||
| `agent_tasks` / `agent_runs` / `agent_actions` | The queue the API writes to and the agent drains, plus what it did | The API never calls the model; it writes a row |
|
||||
|
||||
## What makes it compute-native
|
||||
Two pipelines, with stages taken from how the market operates:
|
||||
|
||||
- **`inventory_listings`** mirrors the Prime Intellect availability API
|
||||
field-for-field — `gpuType`, `socket`, `interconnectType`, `stockStatus`,
|
||||
`security` (secure vs community cloud), `prices.onDemand`, `provisioningTime`.
|
||||
Sync is a straight mapping, not an ETL project.
|
||||
- **`capacity_commitments`** records what you bought: term, GPU-hours,
|
||||
cost per GPU-hour, floor and ceiling.
|
||||
- **`contracts`** is polymorphic over party and type — MSA, DPA, SLA, order
|
||||
form, capacity commitment — because the supply side negotiates heavyweight
|
||||
paper while the self-serve demand side runs on a reliability tier and a
|
||||
credits policy instead of a signed uptime guarantee.
|
||||
- **Two real pipelines**, with stages taken from how this market actually
|
||||
operates rather than invented:
|
||||
```
|
||||
Demand: qualification → legal → scoping → proposal → procurement
|
||||
→ POC → deployment → expansion (+ closed_won / closed_lost)
|
||||
|
||||
```
|
||||
Demand: qualification → legal → scoping → proposal → procurement
|
||||
→ POC → deployment → expansion
|
||||
Supply: sourced → qualifying → technical diligence → financial diligence
|
||||
→ pricing → contracting → onboarding → live → renewal
|
||||
(+ churned / rejected)
|
||||
```
|
||||
|
||||
Supply: sourced → qualifying → technical diligence → financial diligence
|
||||
→ pricing → contracting → onboarding → live → renewal
|
||||
```
|
||||
**Legal sits second** in the demand pipeline. MSA and DPA execution gates the
|
||||
deal rather than closing it. Most CRMs put contracts at the end of the funnel
|
||||
and are wrong about it for this market.
|
||||
|
||||
Note that **legal sits second** in the demand pipeline. MSA and DPA execution
|
||||
gates the deal rather than closing it. Most CRMs put contracts at the end and
|
||||
are wrong about it for this market.
|
||||
Three further decisions worth knowing before you read the schema:
|
||||
|
||||
## Stack
|
||||
- **Holds reserve; they do not sell.** A live hold removes capacity from
|
||||
everyone else's availability — otherwise two sellers promise the same GPUs —
|
||||
but never counts toward utilisation or revenue.
|
||||
- **Security tiers are ranked, not labelled.** `community_cloud` <
|
||||
`secure_cloud` < `government`, and a requirement is satisfied only from at or
|
||||
above its tier.
|
||||
- **Money is integer cents**, rounded exactly once, at the boundary.
|
||||
|
||||
| Layer | Choice |
|
||||
|---|---|
|
||||
| Web | React + Vite + TypeScript, Tailwind, shadcn/ui, light + dark |
|
||||
| API | Hono + tRPC on Node 22+ |
|
||||
| Database | PostgreSQL 16, Drizzle ORM |
|
||||
| Auth | Supabase (JWT verification only — PIG stores no passwords) |
|
||||
| Agent | Piggy — a worker draining a leased task queue |
|
||||
| MCP | `@modelcontextprotocol/sdk` — stdio + Streamable HTTP |
|
||||
| Deploy | Docker Compose behind any reverse proxy |
|
||||
## Self-hosting
|
||||
|
||||
Authorization comes from PIG's own `users` table, never from the mere existence
|
||||
of an auth account. An identity provider that PIG shares with another
|
||||
application must not grant access here.
|
||||
### Requirements
|
||||
|
||||
## Quick start
|
||||
Node 22+, pnpm 11+ (pinned by `packageManager`; `corepack enable` installs it),
|
||||
and a PostgreSQL 16 database that PIG owns exclusively.
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
git clone <this-repo> pig && cd pig
|
||||
corepack enable
|
||||
pnpm install
|
||||
cp .env.example .env # then edit it
|
||||
|
||||
docker run -d --name pig-dev -p 5432:5432 \
|
||||
-e POSTGRES_USER=pig -e POSTGRES_PASSWORD=pig -e POSTGRES_DB=pig \
|
||||
postgres:16-alpine
|
||||
|
||||
export DATABASE_URL=postgres://pig:pig@localhost:5432/pig
|
||||
pnpm run db:migrate
|
||||
pnpm run db:seed # optional — public, sourced, confidence-graded
|
||||
pnpm run dev:api # :8920
|
||||
pnpm run dev:web # :5173
|
||||
pnpm run db:seed # optional — sourced, cited, confidence-graded people
|
||||
pnpm run db:demo # optional — a plausible demo book, prefixed "DEMO — "
|
||||
|
||||
pnpm run dev:api # :8920
|
||||
pnpm run dev:web # :5173, proxies /api to :8920
|
||||
```
|
||||
|
||||
Connect an agent:
|
||||
With no identity provider configured, **authentication is disabled in
|
||||
development** and every request runs as the first user in the table.
|
||||
`loadConfig` refuses to start with `NODE_ENV=production` in that state, so it
|
||||
cannot leak into a deployment.
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
claude mcp add pig -- npx -y @pig/mcp # stdio
|
||||
# or point any MCP client at https://<your-host>/mcp
|
||||
cp .env.example .env # then edit
|
||||
docker compose -p pig up -d db
|
||||
docker compose -p pig run --rm --no-deps app pnpm exec tsx packages/db/src/migrate.ts
|
||||
docker compose -p pig up -d --build app
|
||||
```
|
||||
|
||||
## Repository layout
|
||||
Migrate from a one-off container **before** the app starts, not with `exec`: a
|
||||
release that queries a table its migration has not yet created crash-loops
|
||||
before you can attach to it. Full deployment notes, including the reverse
|
||||
proxy, the release poller and rollback semantics, are in
|
||||
[`deploy/README.md`](./deploy/README.md).
|
||||
|
||||
### Every environment variable
|
||||
|
||||
Read from `apps/api/src/lib/config.ts` (API), `apps/piggy/src/config.ts`
|
||||
(Piggy) and `docker-compose.yml`. **Bold** means no default.
|
||||
|
||||
#### Required
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|---|---|---|
|
||||
| **`DATABASE_URL`** | — | The only unconditionally required value. PIG owns this database exclusively |
|
||||
| **`POSTGRES_PASSWORD`** | — | Compose only; `docker-compose.yml` refuses to start without it |
|
||||
|
||||
In production you must additionally set **either** `SUPABASE_URL` **or**
|
||||
`PIG_OIDC_ISSUER`. The API throws at boot with neither.
|
||||
|
||||
#### Identity
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|---|---|---|
|
||||
| `SUPABASE_URL` | unset | Hosted path. Absent in development ⇒ auth disabled |
|
||||
| `SUPABASE_ANON_KEY` | unset | Public by design; served to the browser via `/api/config` |
|
||||
| `SUPABASE_SERVICE_KEY` | unset | Only for administrative provisioning and self-registration. Warns at boot when set |
|
||||
| `PIG_OIDC_ISSUER` | unset | On-premises path. **Takes precedence over `SUPABASE_URL`** |
|
||||
| `PIG_OIDC_JWKS_URI` | discovered | Set it to skip discovery on an air-gapped network |
|
||||
| `PIG_OIDC_AUDIENCE` | unset | Strongly recommended: without it, any token your provider issued for any application in the same tenant is accepted here. Warns, does not refuse |
|
||||
| `PIG_OIDC_EMAIL_CLAIMS` | provider defaults | Comma-separated, in preference order |
|
||||
|
||||
#### Server
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|---|---|---|
|
||||
| `PIG_PORT` | `8920` | |
|
||||
| `PIG_PUBLIC_URL` | `http://localhost:8920` | The single origin the app is served from; CORS and the Google redirect are validated against it |
|
||||
| `NODE_ENV` | `development` | `production` activates the identity-provider guard |
|
||||
| `PIG_ADMIN_EMAILS` | `''` | Comma-separated. Every address must already have an account — an unregistered address here is a standing offer of admin rights to whoever claims it first |
|
||||
| `PIG_INVITE_CODE` | unset | Set it to gate signup |
|
||||
| `PIG_SETTINGS_ENCRYPTION_KEY` | unset | Base64-encoded 32 bytes. Required for Notion and Google OAuth; secrets written in the admin UI need it |
|
||||
|
||||
#### Prime Intellect
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|---|---|---|
|
||||
| `PRIME_API_KEY` | unset | Scope it to `Availability → Read` only |
|
||||
| `PRIME_API_BASE` | `https://api.primeintellect.ai` | The compute/pods host. Inference is a *different* host — see below |
|
||||
| `PRIME_SYNC_ENABLED` | `false` | Warns if on without a key |
|
||||
| `PRIME_SYNC_INTERVAL_MINUTES` | `30` | |
|
||||
|
||||
#### Piggy
|
||||
|
||||
The API and the Piggy container read overlapping but distinct sets.
|
||||
|
||||
| Variable | Default | Read by | Notes |
|
||||
|---|---|---|---|
|
||||
| `PIGGY_ENABLED` | `false` | API | Gates the chat surface |
|
||||
| **`PIGGY_INFERENCE_API_KEY`** | — | Piggy | Required by the Piggy process. The model credential never reaches the API container |
|
||||
| `PIGGY_INFERENCE_BASE` | `https://api.pinference.ai/api/v1` | both | OpenAI-compatible |
|
||||
| `PIGGY_MODEL` | `nvidia/nemotron-3-nano-30b-a3b` | both | Admin-selectable at runtime too |
|
||||
| `PIGGY_LEASE_SECONDS` | `300` | both | Queue lease duration |
|
||||
| `PIGGY_POLL_INTERVAL_MS` | `2000` | Piggy | |
|
||||
| `PIGGY_MAX_TOKENS` | `1024` | Piggy | |
|
||||
| `PIGGY_WORKER_ID` | `hostname:pid` | Piggy | |
|
||||
| `PIGGY_INTERNAL_URL` | unset | API | `http://piggy:8931` under Compose |
|
||||
| **`PIGGY_INTERNAL_TOKEN`** | — | both | Min 32 chars; required by the Piggy process. Never put it in a query string |
|
||||
| `PIGGY_CHAT_HOST` | `127.0.0.1` | Piggy | |
|
||||
| `PIGGY_CHAT_PORT` | `8931` | Piggy | Never published to the host |
|
||||
| `PIGGY_CHAT_ALLOW_NON_LOOPBACK` | `false` | Piggy | Compose sets `true`, because the API reaches it across the Compose network |
|
||||
|
||||
#### Integrations — all optional, all validated as a group
|
||||
|
||||
Setting one member of a group without the others fails at boot rather than
|
||||
half-working.
|
||||
|
||||
| Group | Variables |
|
||||
|---|---|
|
||||
| Slack | `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET` |
|
||||
| Buzz | `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG` |
|
||||
| Notion import | `NOTION_CLIENT_ID`, `NOTION_CLIENT_SECRET`, `NOTION_REDIRECT_URI` (+ `PIG_SETTINGS_ENCRYPTION_KEY`) |
|
||||
| Google Sheets import | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` (+ `PIG_SETTINGS_ENCRYPTION_KEY`) |
|
||||
|
||||
`GOOGLE_REDIRECT_URI` must be exactly `<PIG_PUBLIC_URL origin>/oauth/google/callback`.
|
||||
|
||||
## Architecture
|
||||
|
||||
A pnpm monorepo. Around 45k lines of TypeScript including tests, 261 tests
|
||||
across five packages, green CI.
|
||||
|
||||
```
|
||||
apps/
|
||||
web/ React + Vite front end
|
||||
api/ Hono + tRPC API, Supabase JWT verification
|
||||
mcp/ MCP server — stdio and Streamable HTTP
|
||||
web/ React 19 + Vite + Tailwind + shadcn-idiom components
|
||||
api/ Hono HTTP API — auth, validation, capacity and contract services
|
||||
piggy/ The agent: a lease-based queue worker plus a private chat server
|
||||
mcp/ MCP server (stdio) — 9 tools
|
||||
cli/ `pig`, the HTTP surface for scripts and agent kernels
|
||||
packages/
|
||||
db/ Drizzle schema, migrations, seed
|
||||
core/ Shared domain types and the ontology
|
||||
core/ Ontology, permissions, margin arithmetic, palette — no I/O
|
||||
db/ Drizzle schema (47 tables), 13 migrations, seed and demo data
|
||||
prime/ Typed client for the Prime Intellect compute API
|
||||
docs/ Ontology, deployment, seed-data provenance
|
||||
deploy/ Compose files and reverse-proxy snippets
|
||||
docs/ ontology.md, build-plan.md, agents.md, seed-data.md
|
||||
deploy/ Caddyfile example, autodeploy units, deployment notes
|
||||
```
|
||||
|
||||
Three rules hold the shape:
|
||||
|
||||
**Intelligence never lives in the API.** Handlers validate, authorise, call a
|
||||
service, serialise. Research, enrichment, scoring and matching heuristics live
|
||||
in the service layer or in the agent. The API signals the agent by *writing a
|
||||
row to `agent_tasks`*, never by calling it — so the queue survives the agent
|
||||
being down and no request thread ever blocks on a model.
|
||||
|
||||
**Authentication is not authorisation.** A verified JWT proves someone has an
|
||||
account in an identity provider PIG may share with another application. Access
|
||||
additionally requires a row in PIG's own `users` table; a token without one
|
||||
gets `403 needs_profile`, which the front end turns into a join flow rather
|
||||
than a login screen they have already completed. Both providers reduce to
|
||||
"verify a bearer token, return a subject and an email" behind
|
||||
`apps/api/src/lib/auth-provider.ts`.
|
||||
|
||||
**Writes go through one chokepoint.** `apps/api/src/lib/mutation.ts` derives
|
||||
zod schemas from the ontology, applies the capability check, runs the write and
|
||||
its audit activity in one transaction, and returns a consistent error shape.
|
||||
|
||||
## The RBAC model, as it now stands
|
||||
|
||||
Eleven capabilities, in `packages/core/src/permissions.ts`, resolved from team
|
||||
membership and role and shared by the API and the browser so a disabled button
|
||||
and a 403 cannot disagree.
|
||||
|
||||
Roles are ranked, and every rule is "at or above": `viewer` < `member` <
|
||||
`lead` < `admin`. A platform admin (an address in `PIG_ADMIN_EMAILS`) holds
|
||||
everything, platform-wide.
|
||||
|
||||
**Writes are team-scoped:**
|
||||
|
||||
| Capability | Teams | Minimum role |
|
||||
|---|---|---|
|
||||
| `deal:write` | supply, demand | member |
|
||||
| `commitment:write` | supply | lead |
|
||||
| `contract:sign` | supply, demand | admin |
|
||||
| `activity:write` | all | member |
|
||||
| `data:import` | all | admin |
|
||||
| `fact:review` | research | admin |
|
||||
| `integration:connect` | all | admin |
|
||||
| `settings:admin` | — | platform admin only |
|
||||
|
||||
**Reads are platform-wide, deliberately:**
|
||||
|
||||
| Capability | Teams | Minimum role | Covers |
|
||||
|---|---|---|---|
|
||||
| `book:read` | all | viewer | Accounts, contacts, both pipelines, contracts, growth, facts |
|
||||
| `economics:read` | supply, demand | member | Supplier cost, break-even price, margin, idle, inventory, the dashboard |
|
||||
| `team:read` | all | viewer | The roster |
|
||||
|
||||
Read grants are **not** team-scoped, and that is a decision rather than an
|
||||
omission: no row-level team filter exists anywhere in the query layer, so a
|
||||
"demand only" read grant would be a promise the guard could not keep. The
|
||||
honest model is that a read capability is held or it is not, and the *role*
|
||||
required to hold it is what separates the roster from the cost book.
|
||||
`economics:read` is the one that matters — supplier cost per GPU-hour and
|
||||
break-even price *are* the business.
|
||||
|
||||
⚠️ **The read half is written and tested but not yet enforced.** The policy
|
||||
table (`apps/api/src/routes/read-guards.ts`) and its middleware exist, and
|
||||
`read-governance.test.ts` fails when a GET appears that no rule covers — but
|
||||
`createReadGuardRoutes` is not mounted in `app.ts`, so today every
|
||||
authenticated member can read the whole book including cost. See
|
||||
[limitations](#what-is-not-built-yet).
|
||||
|
||||
## The agent surface
|
||||
|
||||
PIG is a first-class application for agents *and* for humans, and neither is a
|
||||
degraded view of the other. There are two distinct surfaces.
|
||||
|
||||
### Piggy — the in-app agent
|
||||
|
||||
`apps/piggy` is one image running two processes' worth of behaviour:
|
||||
|
||||
- **The queue worker** claims a task with `SELECT … FOR UPDATE SKIP LOCKED`
|
||||
inside a transaction, holds a renewable lease (default 300s, renewed at half
|
||||
the interval), and aborts its own work if it ever loses that lease — so two
|
||||
workers can never both be mid-flight on one task. Failures retry with
|
||||
exponential backoff capped at one hour, up to the task's `maxAttempts`. Every
|
||||
attempt writes an `agent_runs` row with the model, the input, the token
|
||||
counts and either a summary or the error. Its tool set is exactly two:
|
||||
`pig_get_subject` and `pig_record_fact`, and a fact is refused without both a
|
||||
source URL and an evidence excerpt.
|
||||
- **The chat server** listens on `127.0.0.1:8931` and is never published to the
|
||||
host. The API authenticates the user, forwards bounded context, and calls it
|
||||
with a shared internal bearer token. Chat is **read-only**: seven tools
|
||||
(`pig_get_record`, `pig_get_account_lifecycle` and five page-scoped
|
||||
summaries), each of which aggregates first and returns at most a handful of
|
||||
exemplar rows, because interactive chat runs at 1024 max tokens across at
|
||||
most four turns. Ambient coding tools are rejected before inference by an
|
||||
explicit boundary check.
|
||||
|
||||
Piggy is off by default. `PIGGY_ENABLED` defaults to `false` and the Compose
|
||||
service sits behind `profiles: ['piggy']`, so a default `docker compose up`
|
||||
starts the CRM without it:
|
||||
|
||||
```bash
|
||||
docker compose -p pig --profile piggy up -d --build
|
||||
```
|
||||
|
||||
### The MCP server — for the agent you already use
|
||||
|
||||
`apps/mcp` speaks **stdio** and holds an API key. It calls the same HTTP API a
|
||||
browser does: no database credentials, no privileged path, and deliberately no
|
||||
tool that provisions infrastructure, spends money or emails a customer. Nine
|
||||
tools, because a sprawling tool list measurably degrades model performance:
|
||||
|
||||
| Tool | What it answers |
|
||||
|---|---|
|
||||
| `pig_whoami` | Who am I acting for, and which teams am I on? |
|
||||
| `pig_my_pipeline` | Where are we? What needs attention? |
|
||||
| `pig_capacity_match` | What have we bought that would serve this customer? |
|
||||
| `pig_margin_report` | What is each block earning against what it cost? |
|
||||
| `pig_idle_capacity` | What are we paying for and not selling? |
|
||||
| `pig_inventory_search` | What could we buy to cover demand we cannot serve? |
|
||||
| `pig_search` | Find an account |
|
||||
| `pig_get_account` | Everything about one account |
|
||||
| `pig_log_activity` | Record a call, meeting or note |
|
||||
|
||||
Mint a key in **Settings → API keys** (shown once), then run it from a clone —
|
||||
`@pig/mcp` is a workspace package and is not published to npm:
|
||||
|
||||
```bash
|
||||
export PIG_URL=https://your-pig-host
|
||||
export PIG_API_KEY=pig_...
|
||||
claude mcp add pig -- pnpm --dir /path/to/pig exec tsx apps/mcp/src/stdio.ts
|
||||
```
|
||||
|
||||
There is also a `pig` CLI with `--json` output for scripts and agent kernels;
|
||||
see [docs/agents.md](./docs/agents.md).
|
||||
|
||||
## Shipping — tag to deploy
|
||||
|
||||
CI is Gitea Actions, one sequence, about two minutes. It typechecks every
|
||||
package, applies the migration chain **twice** to a real empty Postgres, asserts
|
||||
the seed is idempotent, runs 261 unit tests and the critical-path E2E, boots the
|
||||
server and curls it, builds the front end, checks the inline theme script still
|
||||
hashes to the value the proxy's CSP allows, and builds the Docker image.
|
||||
|
||||
Shipping is two steps and the second one is a human:
|
||||
|
||||
```bash
|
||||
git tag release-2026-08-13 && git push origin release-2026-08-13
|
||||
```
|
||||
|
||||
1. A push to `main` runs `verify` and stops. **Nothing deploys.**
|
||||
2. A `release-*` tag runs the same `verify`, then `publish` pushes
|
||||
`git.karti.ai/pig/pig:<tag>` and `:<short-sha>` to the registry.
|
||||
3. Within five minutes `pig-autodeploy.timer` on the production host notices
|
||||
the newest release tag has a different digest, checks the tree out at that
|
||||
tag, and runs `scripts/deploy.sh` with `PIG_IMAGE` set.
|
||||
|
||||
The direction of travel is the point: no credential on the shared CI runner can
|
||||
execute anything on the production host. The host holds a pull-only token and
|
||||
fetches. `deploy.sh` dumps the database first, gates on health, the
|
||||
unauthenticated-401 check and a public-origin body marker, and rolls back to
|
||||
the previous image if a gate fails — exiting 1 when the previous image was
|
||||
restored and 3 when the release under test is still live, because that is the
|
||||
one thing an on-call needs at 04:00.
|
||||
|
||||
## What is not built yet
|
||||
|
||||
Said plainly, because you are going to grep the repo anyway.
|
||||
|
||||
**Read authorisation is not enforced.** As above: the policy, the middleware
|
||||
and the governance test all exist; the router line that mounts them does not.
|
||||
Any authenticated member can currently read cost and margin regardless of team
|
||||
or role. This is the most significant gap in the product and it is one line in
|
||||
`app.ts`.
|
||||
|
||||
**Four route modules are written, tested and never mounted.**
|
||||
`routes/read-guards.ts`, `routes/learn.ts`, `routes/hubspot.ts` and
|
||||
`routes/hubspot-webhook.ts` are all absent from `app.ts`. Consequences: the
|
||||
**Learn** page is in the navigation and its API answers 404, and the HubSpot
|
||||
integration — OAuth, connections, sync jobs, webhook verification, seven
|
||||
`hubspot_*` tables — is unreachable. `routes/activities.ts` is likewise
|
||||
unmounted, but harmlessly: an older inline `POST /api/activities` in `app.ts`
|
||||
still serves it.
|
||||
|
||||
**Six of the eight declared agent task kinds are never enqueued.** The worker
|
||||
is complete and generic, but only `enrich_account` and `enrich_contact` are
|
||||
ever written to `agent_tasks` (both from record creation). `write_brief`,
|
||||
`match_capacity`, `detect_idle_capacity`, `summarise_pipeline`,
|
||||
`watch_renewal` and `research_supplier` are declared in the ontology and
|
||||
nothing produces them. Piggy therefore does far less than the queue implies —
|
||||
not because the machinery is missing, but because nothing asks.
|
||||
|
||||
**Piggy chat cannot write.** By design for now, but worth stating: the
|
||||
interactive agent reads and cites; it cannot create or update a CRM record.
|
||||
|
||||
**The MCP server is stdio only.** There is no Streamable HTTP transport and no
|
||||
`/mcp` endpoint on the API, so remote MCP clients cannot connect over the
|
||||
network — each user runs the server locally against their own API key. The
|
||||
package is also not published to npm, so `npx @pig/mcp` does not work.
|
||||
|
||||
**No row-level or team-scoped read filtering exists** anywhere in the query
|
||||
layer. Every read returns the whole book. This is why read capabilities are
|
||||
platform-wide rather than per-team, and it is the thing to build before PIG
|
||||
serves a company where that is not acceptable.
|
||||
|
||||
**`.env.example` is incomplete.** `POSTGRES_PASSWORD` and
|
||||
`PIG_SETTINGS_ENCRYPTION_KEY` are both load-bearing and both missing from it;
|
||||
the table above is authoritative. `ANTHROPIC_API_KEY` is declared in the API
|
||||
config and read by nothing.
|
||||
|
||||
**Not started at all:** email or calendar ingestion, forecasting, quota and
|
||||
attainment, invoicing or billing reconciliation, a public API beyond what the
|
||||
MCP tools cover, multi-tenancy of any kind, and any mobile application. PIG is
|
||||
responsive to 393px; it is not a native app.
|
||||
|
||||
## Documentation
|
||||
|
||||
- **[AGENTS.md](./AGENTS.md) — start here if you are joining this codebase.**
|
||||
Architecture rules, the traps that have already bitten, conventions, and
|
||||
where to start.
|
||||
- [Build plan](./docs/build-plan.md) — what remains, in dependency order
|
||||
Architecture rules, the traps that have already bitten, and conventions.
|
||||
- [Ontology](./docs/ontology.md) — the domain model, and why it is shaped this way
|
||||
- [Build plan](./docs/build-plan.md) — what shipped, what remains, in dependency order
|
||||
- [Agent integration](./docs/agents.md) — MCP clients and the CLI
|
||||
- [Seed data provenance](./docs/seed-data.md) — every claim, graded and cited
|
||||
- [Agent integration](./docs/agents.md) — Claude Code, Codex, prime-agent, Buzz
|
||||
- [Deployment](./docs/deploy.md) — self-hosting
|
||||
- [Deployment](./deploy/README.md) — self-hosting, the release poller, rollback
|
||||
|
||||
## A note on seed data
|
||||
|
||||
PIG ships with a roster of publicly documented people so the application is
|
||||
legible on first run. Every record carries a confidence grade and a source URL.
|
||||
**No email addresses are included or inferred.** Records that could not be
|
||||
independently sourced are marked as such rather than quietly presented as fact,
|
||||
and people who are demonstrably *not* staff — alumni, residency participants —
|
||||
are labelled accordingly. See [docs/seed-data.md](./docs/seed-data.md).
|
||||
legible on first run. Every record carries a confidence grade and a source URL,
|
||||
both shown in the interface. **No email addresses are included or inferred.**
|
||||
Records that could not be independently sourced are marked as such rather than
|
||||
quietly presented as fact, and people who are demonstrably *not* staff —
|
||||
alumni, residency participants — are labelled accordingly. Seeding is opt-in
|
||||
(`pnpm run db:seed`) and never automatic. See
|
||||
[docs/seed-data.md](./docs/seed-data.md).
|
||||
|
||||
If you are seeded here and would rather not be, open an issue and it will be
|
||||
removed.
|
||||
If you are seeded here and would rather not be, open an issue and the record
|
||||
will be removed.
|
||||
|
||||
## Licence
|
||||
|
||||
Apache License 2.0 — see [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
|
||||
Apache License 2.0 — see [LICENSE](./LICENSE) and [NOTICE](./NOTICE). The
|
||||
architectural debts to [Comp AI CRM](https://github.com/trycompai/crm) (MIT)
|
||||
and [Buzz](https://github.com/block/buzz) (Apache-2.0) are credited in NOTICE.
|
||||
No source code was copied from either.
|
||||
|
||||
Reference in New Issue
Block a user