Rebuild the shell, add Calendar and Learn, and govern reads
CI / verify (push) Successful in 3m45s
CI / publish (push) Has been skipped

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:
2026-08-13 15:02:48 -07:00
parent 6cf80747cc
commit 13dec6b4b8
102 changed files with 28638 additions and 913 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ primeintellectgrowth.com, www.primeintellectgrowth.com {
Referrer-Policy "strict-origin-when-cross-origin"
# The app is entirely first-party except for the auth provider, which
# it must reach over XHR.
Content-Security-Policy "default-src 'self'; connect-src 'self' https://*.supabase.co; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'sha256-1tTDwCq+TCEyPDSZeYqW5HbmP+unUg8hrgRiZBiH/IU='; frame-ancestors 'none'; base-uri 'self'"
Content-Security-Policy "default-src 'self'; connect-src 'self' https://*.supabase.co; img-src 'self' data: https://video.karti.ai; style-src 'self' 'unsafe-inline'; script-src 'self' 'sha256-1tTDwCq+TCEyPDSZeYqW5HbmP+unUg8hrgRiZBiH/IU='; frame-src https://video.karti.ai; frame-ancestors 'none'; base-uri 'self'"
-Server
}
}
+186 -7
View File
@@ -43,11 +43,20 @@ a query string, where proxies and access logs can retain it.
## 3. Start
```bash
docker compose -p pig up -d --build
docker compose -p pig exec app pnpm exec tsx packages/db/src/migrate.ts
docker compose -p pig exec app pnpm exec tsx packages/db/src/seed/index.ts # optional
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
docker compose -p pig run --rm --no-deps app pnpm exec tsx packages/db/src/seed/index.ts # optional
```
**Migrate from a one-off container, before the app starts — not with `exec`.**
`exec` needs a running app to attach to, and a release that queries a table its
migration has not yet created crash-loops before you can attach to it. You then
have a container restarting every few seconds and no way in. `run --rm
--no-deps` uses the same image without the app, and without starting its
dependencies twice. This is what commit d4d7095 changed and it is what
`scripts/deploy.sh` does.
When Piggy is enabled, start its private profile as well:
```bash
@@ -76,6 +85,19 @@ Two things that will otherwise cost you an hour:
get a white flash. Editing it changes the hash and CSP will silently block
it — the browser console prints the hash it expects.
**That hash exists in three places, and only two of them are checked.**
| Copy | Checked by |
|---|---|
| `.gitea/workflows/ci.yml` (the `expected` constant) | itself, on every run |
| `deploy/Caddyfile.example` | nothing — it is an example |
| **the live `Caddyfile` on the host** | **nothing at all** |
The live one is the only copy that decides whether a browser runs the script.
Nothing in this repository can see it, CI cannot fail on it, and the failure
is a white flash for dark-mode users with no error anywhere. Editing that
script means editing all three by hand and reloading Caddy.
## 5. Verify
```bash
@@ -83,21 +105,178 @@ curl -s https://primeintellectgrowth.com/api/health
# {"ok":true,"service":"pig","version":"0.1.0"}
```
Check the **public origin**, not just `127.0.0.1:8920`. The `bind` failure
above answers with a valid certificate, HTTP 200 and an empty body, which
satisfies every check that only asks whether something responded.
`scripts/deploy.sh` now asserts the body is non-empty and contains the
application's mount point for this reason.
## Upgrading
### By hand
```bash
git pull
docker compose -p pig up -d --build
docker compose -p pig exec app pnpm exec tsx packages/db/src/migrate.ts
bash scripts/deploy.sh
```
It fetches `origin/main`, dumps the database, builds, migrates from a one-off
container, starts the app, and refuses to call the deploy done until the health
endpoint, the unauthenticated-401 gate and the public origin all agree.
### By tag — the normal path
Shipping is two steps and the second one is a human being:
```bash
git tag release-2026-08-13 && git push origin release-2026-08-13
```
That is the entire ship decision. What follows:
1. CI runs the full `verify` job against the tagged commit — the same job a
push to main runs. A tag does not skip verification.
2. Only if that passes, the `publish` job builds and pushes
`git.karti.ai/pig/pig:<tag>` and `:<short-sha>` to the Gitea registry.
3. Within five minutes `pig-autodeploy.timer` on the host notices that the
newest `release-*` tag has a digest different from the running container,
checks the tree out at that tag, and runs `scripts/deploy.sh` with
`PIG_IMAGE` set — so it pulls the published image instead of rebuilding it.
**Push to main deploys nothing.** Tagging does.
The direction of travel is the point. No credential on the shared CI runner can
execute anything on this host; the host holds a pull-only registry token and
fetches. That preserves both halves of the constraint written at the top of
`scripts/deploy.sh` — no production key on the runner, and a human still
choosing when it ships.
**Trap: `sudo` throws `PIG_IMAGE` away.** The default sudoers policy sets
`env_reset`, so `PIG_IMAGE=… sudo docker compose …` hands compose an environment
without it and compose interpolates the `pig:local` fallback from
`docker-compose.yml`. The pull then dies with "pull access denied for pig" — and
if it had not died, the migrate, the `up` and the rollback would all have run
the stale local image while the log named the release tag. Every compose
invocation in `deploy.sh` therefore goes through the `dc()` wrapper, which uses
`sudo env PIG_IMAGE=… docker compose …`; `sudo -E` and bare `sudo VAR=val` are
both refused by that same policy. Anything new that shells out to compose must
use the wrapper.
### Rollback
`scripts/deploy.sh` records the image the app container was running before it
replaces it. If the health check, the unauthenticated-401 gate, or the
public-origin marker check fails, it re-tags that image, restarts the app on it,
reports whether the restored version is healthy, and exits non-zero. Previously
those exits left the broken release live, which was fine when a human was
watching the terminal and an outage when the poller ran at 04:00.
**The exit code says what is serving**, because that is the one thing the
on-call needs at 04:00 and `autodeploy.sh` can see nothing else:
| Exit | Meaning |
|---|---|
| 0 | deployed |
| 1 | a gate failed and the **previous** image was restored |
| 3 | a gate failed and the **release under test is still live** |
3 covers the cases where a rollback was not attempted (the fault is not
attributable to the release), where there was no previous image to restore, and
where the restore itself did not come up. `autodeploy.sh` logs a different
sentence for each, so the journal never claims a rollback that did not happen.
Two things it deliberately does **not** do:
- **It does not roll the database back.** Migrations are additive, so the
previous image runs against the new schema. The dump taken before the
migration is the escape hatch for when that is not true.
- **It does not roll back when the public origin answers with an EMPTY body.**
Something terminated TLS and replied, so the fault is the proxy — see `bind`
below — and the previous image would fail the same check. Exit 3.
It *does* roll back when the origin answers with a **non-empty** body that lacks
the marker. A proxy fault cannot serve a wrong-but-populated page for this
hostname; a bad release can — a changed Vite `base`, a Dockerfile step that
stopped copying `apps/web/dist`. Every earlier gate passes in that state, so
this is the only one that fires, and refusing to roll back would leave the
broken release facing the public.
If curl cannot reach the public origin **at all** (no hairpin for the public
name, egress to 443 filtered), that is not a failed deploy: it is a host that
cannot see itself from outside, and blacklisting the digest over it costs a
release. The script warns and exits 0. On a host where the public name really is
reachable from the host, set `PIG_DEPLOY_REQUIRE_PUBLIC=1` to make it fatal
(exit 3).
`scripts/autodeploy.sh` writes the failed digest to
`/var/lib/pig/failed-release` and will not retry it, so one bad tag does not
become a five-minute restart loop. Delete that file, or publish a new tag, to
try again.
To pin a specific release by hand:
```bash
git checkout --detach refs/tags/release-2026-08-12
PIG_IMAGE=git.karti.ai/pig/pig:release-2026-08-12 bash scripts/deploy.sh
```
Check the tree out at the tag as well as setting `PIG_IMAGE`. The compose file
and the migrations must come from the same commit as the image; with
`PIG_IMAGE` set, `deploy.sh` deliberately does not touch git, precisely so it
cannot drag you back to `main` behind your back.
Migrations are additive and safe to re-run; Drizzle tracks what has been
applied. Take a dump before a major upgrade anyway:
applied. `deploy.sh` takes a dump before every deploy; take one by hand before
a major upgrade anyway:
```bash
docker compose -p pig exec db pg_dump -U pig pig | gzip > pig-$(date +%F).sql.gz
```
## Installing the release poller
Only on the host that serves production, and only once.
```bash
# 1. A pull-only credential. read:package scope and NOTHING else — a token here
# that can write packages or push to the repository undoes the reason
# deployment is not automated from CI in the first place.
sudo install -d -m 0755 /etc/pig
printf '%s' 'gitea-token-here' | sudo tee /etc/pig/registry-token > /dev/null
sudo chmod 0600 /etc/pig/registry-token
sudo chown root:root /etc/pig/registry-token
# 2. Anything the defaults get wrong. Optional; the script assumes
# /opt/pig, git.karti.ai and pig/pig.
sudo tee /etc/pig/autodeploy.env > /dev/null <<'EOF'
PIG_REGISTRY_USER=pig-deploy
PIG_REPO_DIR=/opt/pig
EOF
sudo chmod 0600 /etc/pig/autodeploy.env
# 3. The units.
sudo cp /opt/pig/deploy/pig-autodeploy.service /etc/systemd/system/
sudo cp /opt/pig/deploy/pig-autodeploy.timer /etc/systemd/system/
sudo systemctl daemon-reload
# 4. Dry-run it once, in the foreground, before trusting a timer with it.
sudo systemctl start pig-autodeploy.service
sudo journalctl -u pig-autodeploy.service -n 50 --no-pager
# 5. Then arm it.
sudo systemctl enable --now pig-autodeploy.timer
systemctl list-timers pig-autodeploy.timer
```
The service is `Type=oneshot` with no `Restart=`, and the timer is not
`Persistent=true`: a missed poll is caught at the next tick rather than fired
at boot, which is when nobody is watching.
Watch a deploy:
```bash
journalctl -u pig-autodeploy.service -f
```
## On-premises: using your own identity provider
PIG authenticates against any standards-compliant OIDC provider, which is how
+38
View File
@@ -0,0 +1,38 @@
# Deploy the newest published release, if there is one.
#
# install: /etc/systemd/system/pig-autodeploy.service
#
# Oneshot, driven by pig-autodeploy.timer. It exits 0 within a second or two on
# the overwhelming majority of runs, because the running digest already matches
# the newest release-* tag.
#
# Runs as root because it drives docker and writes the checkout at /opt/pig.
# The escalation this avoids is the one that matters: no key on the CI runner
# can reach this machine. The credential travels the other way — a pull-only
# registry token in /etc/pig/registry-token.
[Unit]
Description=PIG — deploy the newest published release
Documentation=https://git.karti.ai/PIG/pig/src/branch/main/deploy/README.md
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=oneshot
# Optional: PIG_REGISTRY_USER, PIG_REPO_DIR, PIG_DEPLOY_PUBLIC_URL and friends.
# The leading '-' means a missing file is not an error, so the defaults in the
# script stand on a host that never needed to override anything.
EnvironmentFile=-/etc/pig/autodeploy.env
ExecStart=/bin/bash /opt/pig/scripts/autodeploy.sh
# A build, a migration, a pull and two health waits. Generous, but a deploy cut
# off halfway is worse than a slow one.
TimeoutStartSec=1800
# No Restart=. A failed release must not be retried automatically — the script
# records the failed digest and refuses it on the next tick for the same
# reason.
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+26
View File
@@ -0,0 +1,26 @@
# Poll for a new release every five minutes.
#
# install: /etc/systemd/system/pig-autodeploy.timer
# enable: systemctl enable --now pig-autodeploy.timer
#
# Five minutes is the lag between tagging a release and it being live, on top
# of however long CI takes to publish the image. That is the price of the host
# pulling rather than CI pushing, and it is a fair one.
[Unit]
Description=PIG — poll the registry for a new release
Documentation=https://git.karti.ai/PIG/pig/src/branch/main/deploy/README.md
[Timer]
OnBootSec=5min
OnUnitActiveSec=5min
# Without this every PIG host in a fleet would poll on the same second.
RandomizedDelaySec=60
AccuracySec=30s
# Deliberately NOT Persistent=true. Catching up a missed poll after a long
# downtime would deploy at boot, which is precisely when a human is least
# likely to be watching.
Unit=pig-autodeploy.service
[Install]
WantedBy=timers.target