Commit Graph

18 Commits

Author SHA1 Message Date
karti 14417e34bc Margin table: say "Covered" rather than $0.00 for break-even
CI / verify (push) Successful in 1m51s
Same fix already applied to the capacity cards, missed here. A zero break-even
means the block's cost is fully recovered and any further sale is upside;
printing "$0.00" is technically true and reads like a rendering bug. The demo
book has three such blocks, so it was visible on every screenshot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:58:22 -07:00
karti 468979b303 Add a plausible demo dataset
CI / verify (push) Successful in 2m1s
So the product is legible before anyone has entered real data, and so Piggy
has something to reason about while it is being built.

Kept separate from the base seed because that one is publicly-sourced and cited
while this is invented. Two rules, both deliberate:

Every record is prefixed "DEMO — ", so a screenshot can never be mistaken for
real business. And demand-side customers are fictional. Suppliers are real
companies — they are public, and naming the actual market is the point — but
inventing customers with invented contract values against real named businesses
would be fabricating commercial records about them, which is a different thing
and not worth the extra realism.

The numbers are tuned to teach rather than to flatter. The book clears +5.4% at
79% utilisation, which is thin and about right for this industry once capacity
cost is charged honestly. Underneath, the blocks disagree: the large H200 block
carries it, the EU H100 block is underwater at 55% sold because a 46% markup
needs ~69% sold to break even, and the community pool holds a large unconverted
hold — so the difference between "sold" and "held" is visible rather than
theoretical.

An earlier tuning left the whole book at -26%. Honest, but it reads as a broken
product rather than an under-utilised book, so the totals now open healthy and
the problems appear on drill-down.

Also exercises parts of the schema nothing had touched yet: ramped capacity
shapes, negotiated SLAs with fee abatement and spare-pool scope, renewal
obligations with one deliberately near-term, EU data-residency constraints on a
capacity request, and internal research burn.

Two bugs found while testing it, both the same trap as before: the research
allocation duplicated on every run because onConflictDoNothing() is a no-op
without a matching unique constraint, and notes were double-prefixed. Verified
idempotent over three consecutive runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:55:41 -07:00
karti 2a30645c8d CI: Postgres on 127.0.0.1, because the runner uses host networking
CI / verify (push) Successful in 1m59s
The runner is configured with `container.network: host`. That one setting
explains all three earlier failures, and the workflow now records them so
nobody repeats the sequence:

  services:                     not resolvable by name from a host-networked
                                job — "getaddrinfo EAI_AGAIN postgres"
  --network container:$HOSTNAME /etc/hostname is the HOST's name, not a
                                container id, so the join finds nothing
  default-gateway addressing    wrong idea outright: with host networking the
                                default route is the real router, not a bridge

Sharing the host's network namespace means a published port is just on
127.0.0.1. Readiness is now checked over TCP from the job itself rather than
with pg_isready inside the container — the latter proves the server started,
not that this job can reach it, which is the thing that actually failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:32:06 -07:00
karti e6b4c1618e CI: reach Postgres through the gateway instead of a shared namespace
CI / verify (push) Failing after 5s
Second attempt failed differently: /etc/hostname inside the job reports the
HOST's name rather than the container id, so `--network container:$HOSTNAME`
found no such container.

Rather than hunt for our own container id through /proc, publish the port on
the host and connect through the job container's default gateway. That needs
no container identity at all. The port is derived from the run id so two
concurrent runs cannot collide, and DATABASE_URL is exported through GITHUB_ENV
once Postgres is actually accepting connections.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:30:24 -07:00
karti 40f6fd993d CI: start Postgres as a step rather than a service container
CI / verify (push) Failing after 5s
The first run got through install, typecheck and all 39 tests, then failed on
`getaddrinfo EAI_AGAIN postgres`. This runner does not attach service
containers to the job's network, so the `services:` hostname never resolves.

Fixed by starting Postgres with `--network container:$HOSTNAME`, sharing the
job container's own network namespace so it appears on 127.0.0.1. That works
regardless of how the runner is configured — which matters here because the
runner is shared with other repositories and should not need reconfiguring to
suit this one.

Also queries row counts through `docker exec` rather than a local psql, since
the runner image is not guaranteed to ship postgresql-client, and removes the
container in an `if: always()` step so a failed run does not leave it behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:29:25 -07:00
karti 73231a8944 Add CI, a test suite, and a deploy script
CI / verify (push) Failing after 34s
`npm test` did nothing until now. CI that runs no tests is theatre, so the
tests came first — 39 of them, over the two places where an error would be
silent and expensive.

packages/core: the margin arithmetic. Every dashboard figure, idle-capacity
alert and agent answer resolves through it, and wrong numbers still look like
numbers. The cases pin decisions rather than implementation: cost is charged
against the full commitment (a naive version reports the opposite sign on a
loss-making block), aggregation sums cents rather than averaging percentages
(averaging reports +22% on a book that is losing money), break-even prices the
remaining hours and returns null rather than Infinity when there are none, and
internal research burn counts as cost with no revenue.

packages/prime: the upstream mapping. Rounding rather than truncating cents,
because 2.43 is 2.4299999 in binary and a lost cent compounds across millions
of GPU-hours. And interconnect normalisation, where an unrecognised fabric maps
to Unknown rather than Ethernet — guessing low loses a deal, guessing high
sells a training customer a cluster that cannot train.

CI runs on push and pull request: typecheck all six packages, unit tests,
migrations applied twice to a real Postgres, a seed-idempotency assertion that
fails the build if row counts move on a second run, a server boot, the front-end
build, and a Docker build.

It also asserts the inline theme script's hash still matches the CSP the proxy
allows. That script prevents a white flash for dark-mode users; if it changes
without the CSP being updated, the browser silently blocks it and nothing
anywhere reports an error.

Deployment stays a script rather than push-to-deploy. Automating it would put
an SSH key with production write access on the CI runner — a real escalation
for a project this size. The script takes a database dump before migrating and
refuses to finish if an unauthenticated request returns anything but 401.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:27:47 -07:00
karti 7719850fc5 Let people register with a personal email and an invite code
The invite gate was unreachable. PIG shares its identity provider with another
application, and self-registration there is deliberately switched off — so
somebody with a personal address and a valid invite code could never obtain a
token, and therefore could never reach the endpoint that accepts the code. The
gate was real and nothing could ever arrive at it.

Opening self-registration on the provider would have opened it for the
neighbouring application too, which is precisely why it was closed. So PIG now
mints the account itself through the provider's admin API, and only after the
invite validates. The provider stays shut; the invite becomes the actual gate.

Order is deliberate: validate the invite, create the auth account, create the
profile, consume the invite. If the profile write fails the auth account is
deleted again — otherwise someone could sign in with no profile and no way to
obtain one, because their invite would look spent.

An administrator's address does NOT bypass this. The bypass in /api/signup
exists to bootstrap the first admin from an account that already exists; here
an account is created from nothing, and an ungated version of that is simply
an open registration endpoint.

Registration signs the person in on success rather than returning them to a
login form to retype the password they entered ten seconds earlier. An address
that already exists in the provider but has no PIG profile is detected and
pointed at sign-in, since /api/signup handles that case properly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:15:32 -07:00
karti 045e51bc5c Polish: team page title, and accent swatches that match the theme
The accent swatches always previewed the light-mode value, which made the
near-black "Pig" swatch effectively invisible on a dark card — the one place
the preview needed to be accurate. Each accent is tuned twice because a colour
that reads well on white is usually too dark on near-black; the swatch now
shows the value for the theme actually in effect.

Team and the placeholder routes also set document titles, so every route in
the app is now distinguishable in history and in a tab strip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:55:38 -07:00
karti 7c134140ff Fix static serving — og.png was returning index.html
The server mounted static files at /assets only, so every top-level file fell
through to the SPA catch-all. og.png, apple-touch-icon.png, icon-192.png and
manifest.webmanifest each returned `200 text/html` containing the app shell.

This is a nearly invisible failure. The app works. The tab shows an icon,
because browsers cache the SVG. Nothing errors anywhere. But a link shared to
iMessage, Slack or X fetches og.png, receives HTML, and renders a card with no
image — which is the entire point of having made one.

Now the whole dist directory is served, with /api excluded so routes are never
answered from disk, and the SPA fallback still catching client-side routes that
have no file behind them.

Found by fetching the asset from an outside host instead of trusting that
adding the file was enough. Verified: og.png is image/png at 53KB, the icons
and manifest carry their real types, /margin and /capacity still receive the
shell, and the API is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:51:58 -07:00
karti d154a78d48 Polish: per-route titles and a way to sign out
There was no sign-out control anywhere. Easy to miss when you develop
permanently signed in, and a stranded feeling for anyone on a shared machine.
Clearing the identity provider's session is sufficient — PIG holds no session
of its own — and the auth listener returns to sign-in without a reload. A hard
redirect follows regardless, so a failed provider call cannot leave a
half-signed-out interface.

Every route now sets its own document title. A single static title makes
browser history and a wall of tabs useless: every entry reads "pig" and nobody
can tell the margin view from the pipeline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:49:32 -07:00
karti 46d7d80f1b Fix boolean env parsing — PRIME_SYNC_ENABLED=false was reading as true
z.coerce.boolean() calls Boolean(value), so the string "false" is true. So are
"0", "no" and "off". Every feature flag set to false was silently on.

Caught by reading a startup warning that should not have been there: the
deployment logged "PRIME_SYNC_ENABLED is on but PRIME_API_KEY is unset" while
the .env plainly said false. Had the key been present, PIG would have started
polling a third-party API nobody asked it to poll.

Replaced with an explicit parser accepting 1/true/yes/on, treating an empty or
absent value as the default. Demonstrated both behaviours side by side before
committing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:46:21 -07:00
karti 0551e8dd6e Add link previews and icons; make PIG_INVITE_CODE actually work
Social and icons. A 1200x630 card, apple-touch-icon, and maskable PWA icons,
generated from an HTML template by a script so the mark, wordmark and tagline
cannot drift from the product. The apple-touch-icon referenced in index.html
was a 404 until now. Icons are drawn on an opaque plate with inset because iOS
rounds corners and Android may apply a circle — an edge-to-edge mark loses its
ears to that crop. og:image is absolute, which is the single most common
reason a card unfurls blank.

PIG_INVITE_CODE was a lie. Signup validates against the invites table, so
setting the variable only flipped a label in the UI — an operator would set it,
hand the code to a colleague, and watch them be rejected. It is now reconciled
into a real invite row at boot: setting it issues, changing it rotates and
revokes the predecessor, and removing it revokes. Verified all three, plus that
a restart with an unchanged code does not duplicate.

Two bugs found while doing that:

- `uses_remaining` was jsonb, so the SQL decrement could never have worked.
  Now integer. The generated migration failed because Postgres has no implicit
  jsonb->integer cast, so the USING clause is hand-written.
- Redemption keyed off `redeemedAt`, which would have made every reusable
  invite single-use — a confusing way to lock a team out. Availability now
  comes from `usesRemaining`, and redemption records who used it most recently
  without consuming it.

Sign-in gains a password option alongside the magic link, defaulting to
password since that is the daily path. PIG stores neither; both are handled by
the identity provider and PIG only ever sees the resulting token.
autocomplete is set so password managers and iOS can fill.

Verified: full migration chain applies to a fresh Postgres, the CSP hash for
the inline theme script is unchanged by the rebuild.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:43:10 -07:00
karti 93818a2d2c Document two proxy traps found by deploying rather than assuming
The deployment came up healthy, served a valid certificate, and returned 200 —
and was completely unreachable. Two distinct causes, both invisible from
inside the host:

1. Every other site on this proxy binds to a private VNIC address. Caddy
   groups site blocks into servers BY listen address, so a block without
   `bind` landed in a separate server on :443. The specific listener wins for
   traffic arriving on that address, which is all public traffic after NAT, so
   requests hit the server that had never heard of these hostnames and fell
   through to an empty 200. Testing from the host with --resolve 127.0.0.1
   worked perfectly, which is exactly why this was worth chasing from a third
   machine instead of trusting a local check.

2. The CSP blocked the inline pre-paint theme script, so dark-mode users would
   have seen a white flash on every load. Fixed with the script's hash rather
   than 'unsafe-inline', which would have defeated the policy, and rather than
   an external file, which would have reintroduced the flash. Editing that
   script changes its hash and silently breaks it, so that is written down.

Verified from an independent host: health returns JSON, the app serves, an
unauthenticated API call is refused, the short alias redirects, security
headers are present, and the existing sites on the proxy are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:31:57 -07:00
karti 7bb8835974 Add profile creation — close the gap between signing in and being a member
Deploying and then trying to actually use it surfaced a dead end: /api/signup
was exempted from auth but never implemented, so a real person could sign in,
receive 403 needs_profile, and have nowhere to go. Authentication worked;
joining did not.

The route is mounted before the auth middleware, because requiring membership
to reach the route that grants membership is circular. It verifies the token
itself and then requires one of two things:

- A valid invite code. Stored hashed, optionally pinned to an address,
  optionally expiring, consumed on redemption with the redeemer recorded.
- Presence in PIG_ADMIN_EMAILS. The bootstrap path, which exists because on a
  fresh deployment nobody can issue an invite since nobody can sign in to
  issue one.

The bootstrap path is narrow by construction: the address must be listed in
server-side configuration AND match the verified email claim on the token.
Admin rights are never read from the request body, so a crafted payload
cannot grant them.

Two behaviours worth noting. A row that was invited but never signed into is
claimed rather than rejected, binding it to the identity that just proved
ownership of the address. And a resubmitted form returns the existing user
instead of erroring, because a double-tap should be harmless.

The front end now treats needs_profile as a step in the flow rather than an
error, showing a team picker. Sending someone back to a login screen they have
already completed is a loop with no exit.

Also: the seed no longer creates the dev@localhost admin row under
NODE_ENV=production. It was unreachable (no auth subject, so nobody can sign
in as it), but an admin-flagged placeholder in a real deployment is a trap.

Verified: rejects a missing token, rejects an invalid body, claims a pre-existing
row, and is idempotent on resubmission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:26:43 -07:00
karti c747eb2aa7 Add deployment: Dockerfile, compose, proxy config, and docs
One container plus a Postgres behind any TLS-terminating proxy. Nothing is
specific to a particular host.

The app and API are served from a SINGLE origin. This is not tidiness: browser
auth sessions live in per-origin storage, so splitting them across two
hostnames makes sign-in loop in a way that presents as a server fault. The
short alias redirects rather than serving a second origin.

Two safety properties verified by running the image, not by reading the code:

- With NODE_ENV=production and no SUPABASE_URL, the process refuses to start
  and says why. Serving the whole CRM unauthenticated is a worse outcome than
  failing to deploy, so the failure is deliberate and loud.
- In production the development auth bypass does not apply: an unauthenticated
  request to /api/dashboard returns 401 rather than adopting the first user in
  the table.

The Dockerfile typechecks all six packages as a build gate, so a deploy that
does not compile fails at build time rather than in front of a user. Runtime
runs unprivileged as `node`, and Postgres is not published to the host.

Docs cover the ontology and why it is shaped this way, agent connection for
Claude Code / Codex / prime-agent / Buzz, and the provenance rules governing
seed data about real people — including how to have your record removed.

Verified: image builds, container reports healthy, serves the SPA, enforces
auth, and the production guard exits non-zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:19:53 -07:00
karti de33a03524 Add the web app, seed data, and user-selectable theming
apps/web — React, Vite, Tailwind, shadcn-idiom components. Mobile Safari is a
first-class target, not an afterthought:

- Two navigation treatments rather than one compromise. A bottom tab bar on
  phones, because the top of a large phone is out of thumb reach; a persistent
  sidebar from lg upward, so an iPad in portrait gets it too.
- Safe-area insets throughout, so the tab bar clears the home indicator and the
  last row of a list is actually reachable.
- Inputs are pinned to a 16px minimum, which is the correct fix for Safari
  zooming on focus. user-scalable=no is not used: it breaks pinch-zoom for
  everyone and recent iOS ignores it anyway.
- The pipeline board becomes a stage picker on phones. An eight-column board
  scrolling horizontally on a 390px screen is technically responsive and
  practically useless.

Theming: users pick an accent and the whole interface re-tints. Accent values
live once, in @pig/core, and are written onto the root element at runtime —
there is no CSS copy to drift from the TypeScript. Preferences are stored
server-side so they follow a person between laptop and phone, mirrored into
localStorage only so the pre-paint script can avoid a white flash. Status
colours stay fixed regardless of accent: if "at risk" re-tinted to whatever
someone picked, the signal would be gone.

Seed data is public research, every record carrying a confidence grade and a
source URL. No email addresses are seeded or inferred — none are published, and
guessing them from a name and a domain is unreliable and rude. Authorship is
not promoted to employment: contributors, residency participants and alumni are
recorded as what the evidence actually shows, and a name that could not be
sourced at all is listed as unresolved rather than invented.

Three defects found and fixed by actually running it rather than assuming:

1. The seed was not idempotent. onConflictDoNothing() with no target is a no-op
   without a matching unique constraint, so a second run duplicated 27
   contacts. There is deliberately no unique index on (account, name) — two
   people at one company can share a name — so idempotency is enforced in the
   seed instead of by bending the schema.
2. /capacity scrolled sideways on a phone. Grid items default to
   min-width:auto and `truncate` sets nowrap, so a long title became
   unshrinkable content and widened the track. Fixed with min-w-0 on every
   truncating grid child.
3. The idle-capacity alert silently failed to fire at exactly 80% utilisation,
   losing a float comparison against a 0.2 threshold. Moved to 0.15, which is
   also a more sensible line for "worth attention".

The worked example is tuned to teach rather than to flatter: 70% sold at a 53%
markup lands at +6.7% margin with 20% still idle, so both the healthy number
and the alert are visible. Drop the sold share to 55% and the same block goes
underwater — that sensitivity is the argument for the product.

Verified in a real browser at 393px and 1440px, light and dark: zero horizontal
overflow on every route, zero console errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:16:43 -07:00
karti 7aeec0c632 Add Prime Intellect client, API, and MCP server
packages/prime — a hand-written typed client, because the first-party SDK is
Python only. Deliberately narrow: PIG reads availability and nothing else, and
the key it holds should be scoped so it could not provision even if the code
tried. Rate limits are undocumented upstream, so it backs off empirically with
full jitter and honours Retry-After. Unknown fields survive in `raw` rather
than being dropped.

apps/api — Hono, with authentication and authorization kept firmly apart. A
verified JWT proves someone has an account in the identity project, which may
be shared with other applications; it does NOT prove they belong here. Access
requires a row in PIG's own users table, and a token without one gets 403
needs_profile rather than entry.

The capacity service is the business logic: availability counts sold and held
separately, so a live hold removes inventory from everyone else's availability
without inflating utilisation. Expired holds are ignored at read time, so the
numbers stay right even when the sweeper is behind. Matching treats
interconnect as a hard filter and excludes Unknown as well as Ethernet —
unverified is not the same as adequate.

apps/mcp — nine tools over stdio, so a team member drives PIG from Claude
Code, Codex, prime-agent, or a Buzz agent. It holds an API key and calls the
same HTTP API the browser does, with no database credentials, so an agent can
never reach further than the person it acts for. Results are formatted as
prose rather than raw JSON.

Theme preferences live in the database rather than localStorage, so a chosen
accent follows someone from laptop to phone. Status colours stay independent
of the accent: if "at risk" re-tinted to whatever a user picked, the signal
would be gone.

Note on the SDK import: its package exports use a `./*` wildcard whose types
entry resolves server/mcp.js to server/mcp.js.d.ts, which does not exist. The
runtime specifier must keep the .js suffix, so the types are mapped via
tsconfig paths rather than by writing an import that would fail at runtime.

Verified: all five packages typecheck; the MCP server constructs and registers
its tools.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:02:45 -07:00
karti d36762f264 Scaffold PIG and model the compute-GTM ontology
PIG is an agent-native CRM for two-sided AI-compute companies: businesses
that buy GPU capacity from providers and resell it. Their business is the
spread between two pipelines, which is precisely what a generic CRM cannot
represent.

The load-bearing decision is the `allocations` table, joining a
capacity_commitment (what we bought, at a known cost) to a demand_deal
(what we sold, at a known price). Margin, utilisation and idle capacity all
fall out of that one join. Cost is charged against the full commitment
rather than only the hours that sold, because unsold hours are already paid
for and any other treatment flatters a block that is losing money.

Domain decisions worth noting, each grounded in how this market operates:

- Demand stages put `legal` second, not last. Customers do not hand
  workloads to an infrastructure provider before paper is executed.
- Supply qualification splits technical from financial diligence, recorded
  attributably. Accepting capacity is a two-key decision.
- Capacity carries a time SHAPE (intervals + quantities), not a window.
  Commitments ramp and step down; a rectangle reports availability that
  does not exist in the month someone wants it.
- SLAs model three distinct shapes: none, a reliability tier plus credits
  policy, and a negotiated agreement. Aggregators generally cannot promise
  uptime on resold capacity, but negotiate heavyweight paper upstream.
  Remedies include fee abatement, which is materially better than a capped
  credit and is not expressible as one.
- Export control is a predicate on the allocation edge, evaluated against
  the ULTIMATE parent's jurisdiction. Country of incorporation is not a
  valid key, so this cannot live as a flag on an account.
- Agent-derived claims land in `facts` with a confidence band and evidence.
  Only verified claims self-apply; weaker ones await review.
- The API never calls the agent. It writes to a leased queue, guarded by a
  partial unique index on unfinished work.

Verified: typechecks clean, migration generates and applies to Postgres 16
(31 tables, 24 enums, 117 indexes).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:41:41 -07:00