@everystack/mcp 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,105 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@everystack/mcp` are documented here.
4
+
5
+ Format based on [Keep a Changelog](https://keepachangelog.com/).
6
+
7
+ ## [0.4.8] - 2026-09-07
8
+
9
+ ### Added
10
+
11
+ - pull a published Task image instead of building it at deploy
12
+ - auto-generated changelogs from conventional commits
13
+
14
+ ### Fixed
15
+
16
+ - the task image no longer computes the fingerprint it is judged by
17
+
18
+ ### Documentation
19
+
20
+ - the operator credential is the ops channel only
21
+ - every db verb has a reference entry, and a test says so
22
+ - the disclosed gap names what actually compiles, and a test pins it
23
+ - the last two rollback-point sentences say what the code does; the retargeting edge is named
24
+
25
+ ## [0.4.7] - 2026-08-12
26
+
27
+ ### Fixed
28
+
29
+ - the guidance taught a model that throws, and the config it replaced
30
+
31
+ ## [0.4.6] - 2026-08-05
32
+
33
+ ### Added
34
+
35
+ - a cheat gate for stage-name-as-trust-boundary, and the sweep that found two more
36
+
37
+ ## [0.4.5] - 2026-08-05
38
+
39
+ ### Documentation
40
+
41
+ - the debugging path for a 500 is the request id
42
+
43
+ ## [0.4.4] - 2026-08-01
44
+
45
+ ### Added
46
+
47
+ - dbTask BYO-VPC support for external/public RDS (0.4.2)
48
+ - scope dbTask SSM param under /sst/<app>/<stage>/ (0.4.3)
49
+ - db:reconcile applies an authz-only change as a regrant, not a matview rebuild (0.4.34)
50
+ - db:authz gets a local venue, and the published bin actually runs
51
+
52
+ ### Fixed
53
+
54
+ - db:swap stops fighting its own backup, and its snapshot is now a fact
55
+ - soft-delete visibility is declared, never inferred from a column name
56
+
57
+ ## [0.4.2] - 2026-07-23
58
+
59
+ ### Added
60
+
61
+ - everystack://expo-server-deploy — the Expo Router server app deploy chapter, self-contained
62
+ - db data-movement — restore→Fargate + delete the pg-tools layer
63
+ - expose WAF/caching as an MCP resource — fix the 403-every-PostgREST trap
64
+
65
+ ### Fixed
66
+
67
+ - deploy resource wires the backups path so db:backup isn't unreachable
68
+
69
+ ## [0.4.0] - 2026-07-10
70
+
71
+ ### Added
72
+
73
+ - retire db/sql — descriptors are the single home (Brick B7, read-model-everywhere)
74
+ - 0.4.0 guidance — descriptors, safe remote migration, and the real toolset
75
+
76
+ ## [0.3.3] - 2026-07-04
77
+
78
+ ### Fixed
79
+
80
+ - the gates and generators teach the migration-authority workflow
81
+
82
+ ## [0.3.1] - 2026-07-02
83
+
84
+ ### Added
85
+
86
+ - governance layer — grounding + cheat gates (0.3.0)
87
+ - scaffold + reconcile the project CLAUDE.md
88
+ - runbook prompt — drive the runbook compiler, fill slots only; cross-link in CLAUDE.md template
89
+
90
+ ## [0.2.2] - 2026-06-18
91
+
92
+ ### Added
93
+
94
+ - add @everystack/mcp package, IAM docs, .mcp.json
95
+ - start-static beginner flow, check_environment tools, beginner resources
96
+ - S3 origin cache for processed images
97
+ - dual-export OTA updates, SSR validation, eas.json env resolution
98
+
99
+ ### Fixed
100
+
101
+ - fail closed on cookie auth without allowedOrigins
102
+
103
+ ### Documentation
104
+
105
+ - scope the SST deploy IAM policy to the app's resources
package/dist/cli.md CHANGED
@@ -37,11 +37,20 @@ these verbs move a database. Full operational guidance: everystack://database-op
37
37
  everystack db:sync # Make a dev DB match your checkout — state + authz + compute, one verb
38
38
  everystack db:generate # STATE layer (tables) → next migration file (--dry-run prints the SQL, writes nothing)
39
39
  everystack db:reconcile # Deploy the compute layer only (views/matviews/functions/triggers)
40
+ everystack db:refresh # Refresh the declared matviews in dependency order, credential-free (--only, --verify-nonempty, --list)
40
41
  everystack db:check # CI gate: declared state composes + generated artifacts match regeneration
41
42
  everystack db:fingerprint # Content-address the live base schema vs the Models — MATCH/MISMATCH
42
43
  everystack db:diff # The state edge between two declared states — no DB, CI-pure
44
+ everystack db:build # Build a database FROM the models and keep it (fresh target only; --database-url, never the env)
45
+ everystack db:pull # Introspect a live DB → render field() Models: THE on-ramp for an existing database
43
46
  ```
44
47
 
48
+ **Bringing an existing database in** — `db:pull --stage <s> --abilities live --out db/models`, commit
49
+ what it renders, then `db:plan --stage <s>`. An EMPTY plan means adopted as-is (that commit declares
50
+ the database; every later `db:apply` descends from it). A NON-EMPTY plan is the hardening edge:
51
+ review it, `db:apply` it — no commit declares the database yet, so the reviewed plan is the origin.
52
+ Render from the stage, never from a local restore of a dump.
53
+
45
54
  **Authorization (the brownfield on-ramp + the red team)**
46
55
  ```bash
47
56
  everystack db:authz:pull # Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
@@ -78,6 +87,21 @@ everystack db:backup:download <id> --stage X # Presigned download URL (1h)
78
87
  everystack db:restore --from <id> --stage X --confirm # Restore a backup INTO a stage (destructive)
79
88
  ```
80
89
 
90
+ **Schema artifacts (build elsewhere, land atomically)**
91
+ ```bash
92
+ everystack db:export --schema stats --stage X # ONE schema as a fingerprint-stamped .dump (+ .meta.json)
93
+ everystack db:swap --schema stats --from <artifact> --stage X --direct --confirm # fingerprint gate → confirmed snapshot → restore → one txn → verify; destructive
94
+ ```
95
+ `db:swap`'s venue is explicit (`DATABASE_URL` in the env is refused; `--stage` needs `--direct`), and the
96
+ rollback point is confirmed before the restore begins. Full guide: docs/schema-swap.md.
97
+
98
+ **Roles & health**
99
+ ```bash
100
+ everystack db:provision --stage X # Create the least-privilege role chain (idempotent); --rotate swaps A/B credentials live
101
+ everystack db:doctor --stage X # Check the DB is least-privilege + RLS-subject (api vs operator connection)
102
+ everystack db:reset --stage dev # Drop all schemas + re-run migrations (dev only; migration-era apps)
103
+ ```
104
+
81
105
  **Local dev databases**
82
106
  ```bash
83
107
  everystack db:template:refresh # Build the dev template from declared state + seed
@@ -88,6 +112,7 @@ everystack db:fork --from-stage src --stage tgt --confirm # Fork a deployed st
88
112
  **Data**
89
113
  ```bash
90
114
  everystack db:seed --stage dev # Declarative seed script (dev only; refuses on production)
115
+ everystack db:exec fix.sql --stage X # Credential-free DML-only write, one txn; ANY schema change rolls it back (--confirm on prod-tier)
91
116
  everystack db:backfill --apply # One-shot data moves from db/backfills/*.sql (own record)
92
117
  everystack pipeline:run --stage X # Ingest pipeline (credential-free in the ops Lambda)
93
118
  everystack db:migrate # Run the generated migration on a deployed stage via Lambda invoke
package/dist/core.md CHANGED
@@ -104,7 +104,7 @@ const handler = createHandler(db, schema, {
104
104
  protectedFields: { profiles: ['role'] }, // Strip from writes
105
105
  rowOwnership: { posts: { column: 'authorId', userField: 'sub' } },
106
106
  hooks: { posts: { beforeCreate: async (body, user) => ({ ...body, authorId: user?.sub }) } },
107
- softDelete: { column: 'deletedAt', tables: ['posts'] },
107
+ softDelete: { column: 'deletedAt', tables: ['posts'] }, // Models: derived, don't hand-write
108
108
  maxEmbedDepth: 3,
109
109
  maxLimit: 1000,
110
110
  });
@@ -133,11 +133,28 @@ the database URL**. `db:apply` verifies → applies → verifies, and **refuses*
133
133
 
134
134
  - **the live fingerprint still equals `plan.from`** (the concurrency lock — nobody changed
135
135
  the target since you minted the plan; if they did, re-mint), AND
136
+ - **the schema's default privileges still hash to `plan.defaultAclHash`** (an
137
+ `ALTER DEFAULT PRIVILEGES` since minting changes what a created object lands with; the
138
+ refusal happens BEFORE dispatch, nothing lands — re-mint), AND
136
139
  - **your checkout descends from the commit that declares the target's state** (the
137
140
  fast-forward rule — "rebase first"; a stale checkout can't silently revert merged work).
141
+ When NO commit on any ref declares the live state — an existing database being hardened
142
+ in, or a stage hand-edited since its last apply — that is PROVEN drift over a full, current
143
+ clone, and it PASSES: the reviewed plan is the origin every later apply descends from,
144
+ recorded on the `schema_log` row. Shallow or behind-remote clones are UNVERIFIABLE and
145
+ refuse with a fetch runbook.
138
146
 
139
147
  Every refusal is recorded in `everystack.schema_log`. `--database-url` is local-dev-only;
140
- protected stages always go through `--stage`.
148
+ protected stages always go through `--stage`. Every refusal is **staleness** (re-mint, never
149
+ a flag) or **intent** (a named attestation, `--confirm`, an approver) — there is no `--force`.
150
+
151
+ The plan's `to` is the FULL endpoint: it carries the grants of roles your models do not
152
+ govern (a BI reader, an ETL role) through unchanged, so on a brownfield stage an empty plan
153
+ prints `from == to` and still names those roles and the baseline that admitted them.
154
+
155
+ **After upgrading the CLI, redeploy the stage before a staged apply.** `db:apply --stage`
156
+ is a wire contract between the CLI and the ops Lambda; a version-skewed Lambda is refused
157
+ before any write, with the redeploy named.
141
158
 
142
159
  ### 5. Destructive plans carry more gates
143
160
 
@@ -145,8 +162,15 @@ A plan is DESTRUCTIVE if it drops (table/column/type) or narrows a type (a lossy
145
162
  `SET DATA TYPE`). Those additionally require, all of them:
146
163
 
147
164
  - **`--confirm`** — always, explicitly.
148
- - **A snapshot** — taken automatically via `db:backup` when you pass `--stage` (or supply
149
- one with `--snapshot-ref`). No snapshot, no destructive apply.
165
+ - **A confirmed rollback point** — on a stage that exposes `databaseInstanceId`, a physical RDS
166
+ snapshot taken and waited to `available` before the write (the default); otherwise a logical
167
+ `db:backup` waited to completion; or one you attest with `--snapshot-ref <id>`. No rollback
168
+ point, no destructive apply.
169
+ - **The `--direct` lane.** The ops-Lambda lane (`--stage X` alone) REFUSES destructive plans
170
+ outright. Run them `--stage X --direct`: the CLI resolves the stage's operator connection
171
+ from its ops Lambda, holds it in memory only (never on argv), and runs the full ceremony
172
+ on one real connection with an unbounded clock. `--database-url` is not the venue for a
173
+ deployed stage's destruction.
150
174
  - **The stage's approver set** — if declared, `db:apply` verifies the caller's AWS identity
151
175
  (STS) against it. Set it with `db:approvers`:
152
176
  ```bash
@@ -178,11 +202,13 @@ everystack db:backups --stage production # list logical backups
178
202
  everystack db:backup:download <id> --stage production # presigned download URL (1h)
179
203
  ```
180
204
 
181
- **RDS snapshot first?** For a destructive apply, `db:apply` already forces a logical backup
182
- you cannot destroy without one. But before any large or risky migration on a stage that
183
- holds real data, take a **physical `db:snapshot` first** as well: it is instant, it captures
184
- the whole instance, and restoring it is the fastest path back if something the plan didn't
185
- foresee goes wrong. Cheap insurance; take it.
205
+ **RDS snapshot first?** A destructive `db:apply` confirms a rollback point BEFORE it writes: on
206
+ a stage exposing `databaseInstanceId` that is a physical RDS snapshot waited to `available`
207
+ (the default), otherwise a logical `db:backup` waited to completion, or one you attest with
208
+ `--snapshot-ref <id>`. The only way to destroy without one is to say so on the record with
209
+ `--snapshot none`. So a separate `db:snapshot` first is redundant on the default lane — take one
210
+ before a large or risky migration only when the stage has no `databaseInstanceId`, or when you
211
+ want a named restore point that outlives the apply.
186
212
 
187
213
  ## Importing data into a REMOTE stage
188
214
 
@@ -207,22 +233,30 @@ can't dump production. `--database-url` exists only for local dev databases you
207
233
  ## Command index
208
234
 
209
235
  ```bash
236
+ # Bring an existing database in:
237
+ everystack db:pull --stage X --abilities live --out db/models # render field() Models from live; commit; then db:plan (EMPTY = adopted)
238
+
210
239
  # Declare (edit files), then:
211
240
  everystack db:sync # dev DB ← your checkout (state + compute), one verb
212
241
  everystack db:reconcile # compute layer only (views/matviews/functions/triggers)
242
+ everystack db:refresh # repopulate the declared matviews in dependency order (credential-free)
213
243
  everystack db:generate # STATE layer → migration file / --apply / --dry-run
244
+ everystack db:build --database-url "$FRESH" # build a database FROM the models and keep it (empty target only)
214
245
  everystack db:check # CI gate: declared state composes; artifacts match
215
246
  everystack db:fingerprint # content-address live schema vs models — MATCH/MISMATCH
216
247
 
217
248
  # Reach a protected stage safely:
218
249
  everystack db:plan --stage X --out X.plan.json # mint a reviewable edge (read-only)
219
250
  everystack db:apply --plan X.plan.json --stage X # verify → apply → verify (credential-free)
251
+ everystack db:apply --plan X.plan.json --stage X --direct --confirm # a DESTRUCTIVE plan: the direct lane, full ceremony
220
252
  everystack db:approvers --stage X --set "…" # who may run destructive applies
221
253
 
222
- # Back up / restore:
254
+ # Back up / restore / move a schema:
223
255
  everystack db:snapshot --stage X # physical RDS snapshot (instant DR)
224
256
  everystack db:backup --stage X # logical pg_dump → S3
225
257
  everystack db:restore --from <id> --stage X --confirm
258
+ everystack db:export --schema stats --stage X # ONE schema as a fingerprint-stamped artifact
259
+ everystack db:swap --schema stats --from <artifact> --stage X --direct --confirm # land it atomically (destructive)
226
260
 
227
261
  # Local dev databases:
228
262
  everystack db:template:refresh # build the dev template from declared state + seed
@@ -230,6 +264,7 @@ everystack db:branch # per-branch dev DB from the template
230
264
 
231
265
  # Data:
232
266
  everystack db:seed --stage dev # declarative seed (dev only)
267
+ everystack db:exec fix.sql --stage X # credential-free DML-only write, one txn (any schema change rolls it back)
233
268
  everystack db:backfill --apply # one-shot data moves (db/backfills/*.sql)
234
269
  everystack pipeline:run --stage X # ingest pipeline (credential-free)
235
270
  everystack db:fork --from-stage prod --stage feature --confirm # copy a stage's data
@@ -254,8 +254,53 @@ Two requirements on the AWS side:
254
254
  - **Deploy IAM (`>= 0.4.3`):** dbTask writes its operator-URL SecureString to
255
255
  `/sst/<app>/<stage>/DbTaskOperatorUrl`, under the `parameter/sst/*` prefix an SST deploy role is
256
256
  already scoped to. A least-privilege deploy principal also needs ECS (cluster + task-def),
257
- ECR (auth + repo push), EC2 `Describe*` (subnets/SGs), and `logs:CreateLogGroup` for the task's
258
- log group. Override the param path with `paramName` to fit a different scoped prefix.
257
+ EC2 `Describe*` (subnets/SGs), and `logs:CreateLogGroup` for the task's log group. Override the
258
+ param path with `paramName` to fit a different scoped prefix. (ECR auth + repo push is needed
259
+ only with `build: true` — see below. The default pulls a published image and pushes nothing.)
260
+
261
+ ### The Task image — no Docker on the deploy machine
262
+
263
+ `dbTask` pulls a **published, digest-pinned** image by default, so `everystack deploy` needs no
264
+ Docker daemon. Earlier versions built the image locally on every deploy: a daemon that died
265
+ mid-run took the deploy down *after* other resources had already changed.
266
+
267
+ The image is a public GHCR package, so Fargate pulls it with no credential — nothing to rotate,
268
+ and no registry token in your AWS account. It carries no secret; the operator URL still arrives at
269
+ container start via ECS `secrets`/`valueFrom`.
270
+
271
+ ```typescript
272
+ dbTask({ sst, aws }, 'DbTask', { vpc, adminDatabaseUrl, pgMajor: 16 }); // pulls, no Docker
273
+ dbTask({ sst, aws }, 'DbTask', { …, build: true }); // builds locally
274
+ dbTask({ sst, aws }, 'DbTask', { …, image: 'my-mirror/db-task@sha256:…' }); // your own copy
275
+ ```
276
+
277
+ - **`pgMajor` selects the image.** A major with no published image is REFUSED at deploy-plan time,
278
+ before any resource is created — it never silently falls back to a local build, because a silent
279
+ fallback is how the invisible Docker dependency came back.
280
+ - **`build: true`** is the escape hatch for a custom entrypoint or an unpublished major. It needs a
281
+ Docker daemon, and it checks for one up front rather than failing at the build step.
282
+ - **`image`** takes any reference verbatim: a mirror, an air-gapped copy, a digest you pinned.
283
+ - **Pinned by digest, never by tag.** A tag can be repointed at different bytes after a deploy was
284
+ reviewed; `repo@sha256:…` is the same bytes forever.
285
+ - **`architecture` costs nothing on the pull path.** The published image is a multi-arch manifest,
286
+ so the registry resolves arm64 vs x86_64 at pull time regardless of your machine. Only
287
+ `build: true` re-couples the image to the deploy machine's architecture.
288
+
289
+ **The image is an appliance — it runs pg binaries, it does not compute policy.** A backup's
290
+ fingerprint (what the destructive-apply gate compares against `plan.from`) is computed by the CLI,
291
+ before and after the dump, and passed into the task. The task never computes one. A published,
292
+ digest-pinned image that computed it would be running CLI internals frozen at publish time while
293
+ your checkout moved on, and the skew would surface only when the gate trusted a fingerprint your
294
+ CLI would have computed differently.
295
+
296
+ Two consequences:
297
+
298
+ - **`everystack db:backup` can now refuse.** If the schema changes during the dump, the artifact is
299
+ retracted — a dump spanning a migration matches neither endpoint, so it is not a safety net.
300
+ `db:fork` and `db:swap --snapshot logical` take the same path.
301
+ - **Upgrade the CLI and the ops function together.** The ops action refuses a backup that arrives
302
+ with no fingerprint, so an older `@everystack/cli` against a newer deploy fails with a named
303
+ error telling you to upgrade. It does not silently write an unstamped artifact.
259
304
 
260
305
  **Keep `publicIp`, never `public: true`.** SST marks `publicIp` `@deprecated` in favor of `public`,
261
306
  but do NOT swap it here: `public: true` attaches an inbound security group open to `0.0.0.0/0` on
@@ -100,6 +100,7 @@ maxLimit: 1000, // Max ?limit= value (default: 10000)
100
100
  ### Other
101
101
  ```typescript
102
102
  softDelete: { column: 'deletedAt', tables: ['posts'] }, // DELETE -> UPDATE SET deletedAt
103
+ // ^ With Models this is DERIVED from each model's `softDelete: true` — do not hand-write it.
103
104
  naming: 'snake_case', // Response key format ('camelCase' default)
104
105
  ```
105
106
 
package/dist/index.cjs CHANGED
@@ -22411,9 +22411,12 @@ function registerDesignSchemaPrompt(server) {
22411
22411
  "Declare each table with `defineModel`, following these conventions:",
22412
22412
  "- UUID primary keys: `id: field.uuid().primaryKey().defaultRandom()`",
22413
22413
  "- Timestamps: `createdAt: field.timestamptz().defaultNow().notNull()`",
22414
- "- Soft delete: a `deletedAt: field.timestamptz()` field PLUS `softDelete: true` \u2014 the flag is what",
22415
- " excludes soft-deleted rows from public reads and the data API. The field alone does nothing:",
22416
- " visibility is declared, never inferred from a column name",
22414
+ "- Soft delete: a `deletedAt: field.timestamptz()` field PLUS `softDelete: true` \u2014 the field alone",
22415
+ " does nothing. The flag decides DURABILITY (true makes DELETE mark deleted_at instead of",
22416
+ " destroying the row, and hides marked rows from the data API) and, where there is a public",
22417
+ " read, VISIBILITY (deleted_at IS NULL in the anon policy). defineModel REFUSES to guess on any",
22418
+ " model with deletedAt that has a public read or can be deleted from, so it throws at import if",
22419
+ " you omit it. Never hand-write the handler softDelete config \u2014 it is derived from this flag",
22417
22420
  "- Foreign keys via relations: `field.uuid().references(() => Author)` / `belongsTo`/`hasMany`",
22418
22421
  "- Sensitive columns: `.private()` (hidden from the API); write-guarded: `.readonly()`",
22419
22422
  "- Named exports (PascalCase model var, e.g. `export const Post = defineModel('posts', \u2026)`)",
@@ -23761,7 +23764,7 @@ async function runGovernanceCli(argv) {
23761
23764
  }
23762
23765
 
23763
23766
  // src/index.ts
23764
- var version2 = (true ? "0.4.6" : null) ?? "0.3.0-dev";
23767
+ var version2 = (true ? "0.4.8" : null) ?? "0.3.0-dev";
23765
23768
  var INSTRUCTIONS = [
23766
23769
  "You govern how any agent builds everystack \u2014 a self-hosted application stack for Expo apps on AWS.",
23767
23770
  "Your job is not only to advise but to keep the build on-script: the architecture the maintainer",
@@ -34,6 +34,9 @@ export const Post = defineModel('posts', {
34
34
  updatedAt: field.timestamptz().notNull().defaultNow(),
35
35
  deletedAt: field.timestamptz(),
36
36
  },
37
+ // Required here, not optional: this model has a `deletedAt` field AND grants DELETE, so
38
+ // `defineModel` refuses to guess. See Soft Delete below for what each value means.
39
+ softDelete: true,
37
40
  abilities: [can('read'), can('create'), can('update'), can('delete')],
38
41
  });
39
42
  ```
@@ -59,11 +62,34 @@ updatedAt: field.timestamptz().notNull().defaultNow(),
59
62
  ```
60
63
 
61
64
  ### Soft Delete
62
- Add `deletedAt` for soft-deletable tables:
65
+ The field alone does nothing. Declare BOTH — the column and what it means:
63
66
  ```typescript
64
67
  deletedAt: field.timestamptz(),
68
+ softDelete: true, // on the model, beside `fields` — NOT in the handler
65
69
  ```
66
- Configure in handler: `softDelete: { column: 'deletedAt', tables: ['posts'] }`.
70
+
71
+ **`defineModel` REFUSES to guess** when a model has `deletedAt` and either a public read or a
72
+ way to DELETE (`can('delete')`, `can('manage')` — which is "all actions" — or a `privileges`
73
+ DELETE grant). Omit the line on such a model and it throws at import.
74
+
75
+ It is declared because it changes two different things, and neither default is safe:
76
+
77
+ - **Durability, on every model** — `true` makes a `DELETE` mark `deleted_at` instead of
78
+ removing the row, and then hides marked rows from the data API's reads and updates. `false`
79
+ makes `DELETE` permanent.
80
+ - **Visibility, only where there is a public read** — `true` AND-s `deleted_at IS NULL` into
81
+ the anon policy. On an owner-scoped or admin-only model the compiled SQL is identical either
82
+ way, so the durability half is the one that matters there.
83
+
84
+ Pick `true` if the row is meant to survive its own deletion — anything with a cleanup or purge
85
+ job, an audit trail, or rows other tables reference. Pick `false` if `deleted_at` here is
86
+ audit-only and a `DELETE` really should remove the row. Getting this wrong is not cosmetic: a
87
+ table whose S3 cleanup job finds work by `deleted_at IS NOT NULL` orphans every file it should
88
+ have purged if the row is hard-deleted instead.
89
+
90
+ **Do not hand-write the handler's `softDelete: { column, tables }`.** With Models, the handler
91
+ config is DERIVED from this flag (`deriveHandlerConfig`), so writing both is how the two drift
92
+ apart — and a hand-written list silently losing a table is exactly how this bug shipped.
67
93
 
68
94
  ### Foreign Keys
69
95
  ```typescript
@@ -120,13 +146,37 @@ export const Post = defineModel('posts', {
120
146
  fields: { /* … */ },
121
147
  abilities: [
122
148
  can('read'), // anon + authenticated
123
- can('create', { role: 'authenticated' }),
124
- can('update', { role: 'authenticated', own: 'authorId' }), // row ownership
125
- can('delete', { role: 'admin' }),
149
+ can('create', { role: 'authenticated' }), // signed-in users insert rows THEY OWN — the model has an owner (below), so WITH CHECK (author_id = caller)
150
+ can('update', { owner: 'authorId' }), // row ownership: only the author, via RLS
151
+ can('manage', { role: 'admin' }), // the admin bypass: one ALL policy, true/true
126
152
  ],
127
153
  });
128
154
  ```
129
155
 
156
+ **How a write compiles — read this before naming a role.** A write ability is a GRANT plus an
157
+ RLS policy, and RLS is on for every model with abilities, so a grant with no policy is dead:
158
+ an INSERT fails loudly (`new row violates row-level security policy`), while an UPDATE or
159
+ DELETE with no policy affects **0 rows silently** — no error, nothing changes.
160
+
161
+ - `owner:` / `via:` writes compile the owner-checked policy (`<t>_insert_own`, `_update_own`,
162
+ `_delete_own`) for `authenticated`. On such a model a bare `can('create')` or
163
+ `can('create', { role: 'authenticated' })` is ALSO the owner-checked insert — the example above.
164
+ - `can('manage', { role: 'admin' })` is the admin bypass: one `ALL` policy, `true`/`true`.
165
+ - **Known gap (cli 0.4.80):** a role-scoped or bare write with NO owner on the model and no
166
+ `where:` — `can('create', { role: 'authenticated' })` on an owner-less table, `can('delete',
167
+ { role: 'admin' })`, `can('manage', { role: 'editor' })` — compiles the grant and NO policy. It
168
+ reads as access and grants none. A vacuous `where: sql\`true\`` does NOT help: the compiler
169
+ discards a `true` predicate and still emits nothing. What compiles today: `owner:` / `via:`
170
+ for row-scoped writes; `can('manage', { role: 'admin' })` for the admin bypass; and, for
171
+ `authenticated` only, a real (non-vacuous) `where:` on a `create`/`update`/`delete`, which
172
+ compiles a predicate-only policy for `authenticated`. There is no in-model spelling for a
173
+ role-wide write by any other role — and do not try a `where:` on another role's write: the
174
+ write compiler never reads the declared role, so `can('update', { role: 'editor', where })`
175
+ grants editor and lands the predicate on the **`authenticated`** policy. Run that write on the
176
+ trusted admin connection until the write-policy matrix ships.
177
+ - `check:` is the WITH CHECK half verbatim — it REPLACES the owner term, it does not narrow it —
178
+ so a `check:` on an owner model must carry the owner term itself.
179
+
130
180
  ## Handler Config for Schema
131
181
 
132
182
  The handler config is **derived** from the Models (`deriveHandlerConfig(models)`), so
package/dist/security.md CHANGED
@@ -80,6 +80,56 @@ GRANT admin TO authenticator;
80
80
 
81
81
  Why `NOINHERIT`: authenticator has zero privileges. All access comes through the granted roles. This prevents accidental privilege leakage.
82
82
 
83
+ ### The Two Credential Channels
84
+
85
+ There are exactly two database credentials, and they are not interchangeable.
86
+
87
+ | | Role | Used by | Never |
88
+ | --- | --- | --- | --- |
89
+ | `DATABASE_URL` | `authenticator` (least privilege) | The app plane. Every request-serving read and write, always through `withRole` / `pgSettings`. | — |
90
+ | `ADMIN_DATABASE_URL` | `migrator` (owns the schema) | The ops channel ONLY: migrate, seed, console, backup. | **Never held by request-serving code.** |
91
+
92
+ **The rule: the operator credential is the ops channel only.** It owns the schema, so RLS
93
+ does not apply to it. A request handler holding that credential has unrestricted database
94
+ access, and every RLS policy you wrote is decoration. RLS is only fail-closed once the
95
+ credentials are split — a single privileged URL means every policy fails *open*.
96
+
97
+ This is the mistake to watch for, because it does not look like one. `createAdminDb()` and
98
+ `getAdminSql()` are easy to reach from app code and will work. Working is exactly the
99
+ problem: the failure is silent and total.
100
+
101
+ #### Trusted writes: use a service role, not the operator credential
102
+
103
+ Some writes legitimately need to bypass a user's RLS scope — issuing a credential at signup,
104
+ recording a webhook, a background reconcile. **Do not reach for `ADMIN_DATABASE_URL`.** The
105
+ lawful pattern is a NOLOGIN service role, granted to `authenticator`, entered through
106
+ `withRole` *after* the handler has done its own authorization:
107
+
108
+ ```sql
109
+ -- A NOLOGIN role with exactly the privileges the trusted lane needs, and no more.
110
+ CREATE ROLE service NOLOGIN;
111
+ GRANT service TO authenticator;
112
+
113
+ GRANT SELECT, INSERT, UPDATE ON credentials TO service;
114
+ -- Give it a policy, or leave the table RLS-enabled with a policy that admits `service`.
115
+ ```
116
+
117
+ ```typescript
118
+ // The handler authorizes FIRST — the role is never derived from the token.
119
+ if (!(await mayIssueCredential(user))) throw new HttpError(403);
120
+
121
+ await withRole(sql, 'service', { sub: user.sub, role: 'service' }, async (tx) => {
122
+ await tx`INSERT INTO credentials ${tx(row)}`;
123
+ });
124
+ ```
125
+
126
+ Note that `roleFor()` returns only `anon` and `authenticated` — the roles a token may
127
+ produce. `service` is never returned from a token, so a forged or absent JWT cannot reach it.
128
+ Server code sets it explicitly, on the same least-privilege connection, after its own check.
129
+
130
+ The result: the trusted lane is scoped to the privileges that lane actually needs, and it is
131
+ still on the app plane. The operator credential stays in the ops channel where it belongs.
132
+
83
133
  ### Table Grants
84
134
 
85
135
  ```sql
@@ -260,6 +310,12 @@ RESET ROLE;
260
310
  - [ ] ENABLE + FORCE ROW LEVEL SECURITY on all public tables
261
311
  - [ ] Write RLS policies per table x role
262
312
  - [ ] Connect as `authenticator` (never use RDS master/superuser in production)
313
+ - [ ] Split the credentials: `DATABASE_URL` = authenticator, `ADMIN_DATABASE_URL` = migrator
314
+ - [ ] No request-serving code holds the operator credential — grep the server for
315
+ `createAdminDb` / `getAdminSql` and confirm every hit is an ops path (migrate, seed,
316
+ console, backup), not a request handler
317
+ - [ ] Trusted writes go through a NOLOGIN service role via `withRole`, not the operator
318
+ credential
263
319
  - [ ] Test: SET ROLE authenticated; SELECT returns only expected rows
264
320
  - [ ] Test: a 500 returns only `error` and `requestId` — no relation or column names
265
321
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/mcp",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Governance layer that governs how any agent builds everystack — grounding, cheat gates, and Model-aware tooling over MCP",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -19,10 +19,12 @@
19
19
  "files": [
20
20
  "dist",
21
21
  "src",
22
- "README.md"
22
+ "README.md",
23
+ "CHANGELOG.md"
23
24
  ],
24
25
  "exports": {
25
26
  ".": {
27
+ "source": "./src/index.ts",
26
28
  "types": "./src/index.ts",
27
29
  "default": "./src/index.ts"
28
30
  }
@@ -40,9 +42,9 @@
40
42
  "tsx": "4.21.0",
41
43
  "typescript": "5.9.3",
42
44
  "zod": "3.25.67",
43
- "@everystack/cli": "0.4.49",
44
- "@everystack/model": "0.4.13",
45
- "@everystack/server": "0.4.19"
45
+ "@everystack/cli": "0.4.80",
46
+ "@everystack/server": "0.4.30",
47
+ "@everystack/model": "0.4.17"
46
48
  },
47
49
  "scripts": {
48
50
  "test": "jest",
@@ -41,9 +41,12 @@ export function registerDesignSchemaPrompt(server: McpServer): void {
41
41
  'Declare each table with `defineModel`, following these conventions:',
42
42
  '- UUID primary keys: `id: field.uuid().primaryKey().defaultRandom()`',
43
43
  '- Timestamps: `createdAt: field.timestamptz().defaultNow().notNull()`',
44
- '- Soft delete: a `deletedAt: field.timestamptz()` field PLUS `softDelete: true` — the flag is what',
45
- ' excludes soft-deleted rows from public reads and the data API. The field alone does nothing:',
46
- ' visibility is declared, never inferred from a column name',
44
+ '- Soft delete: a `deletedAt: field.timestamptz()` field PLUS `softDelete: true` — the field alone',
45
+ ' does nothing. The flag decides DURABILITY (true makes DELETE mark deleted_at instead of',
46
+ ' destroying the row, and hides marked rows from the data API) and, where there is a public',
47
+ ' read, VISIBILITY (deleted_at IS NULL in the anon policy). defineModel REFUSES to guess on any',
48
+ ' model with deletedAt that has a public read or can be deleted from, so it throws at import if',
49
+ ' you omit it. Never hand-write the handler softDelete config — it is derived from this flag',
47
50
  '- Foreign keys via relations: `field.uuid().references(() => Author)` / `belongsTo`/`hasMany`',
48
51
  '- Sensitive columns: `.private()` (hidden from the API); write-guarded: `.readonly()`',
49
52
  '- Named exports (PascalCase model var, e.g. `export const Post = defineModel(\'posts\', …)`)',
@@ -37,11 +37,20 @@ these verbs move a database. Full operational guidance: everystack://database-op
37
37
  everystack db:sync # Make a dev DB match your checkout — state + authz + compute, one verb
38
38
  everystack db:generate # STATE layer (tables) → next migration file (--dry-run prints the SQL, writes nothing)
39
39
  everystack db:reconcile # Deploy the compute layer only (views/matviews/functions/triggers)
40
+ everystack db:refresh # Refresh the declared matviews in dependency order, credential-free (--only, --verify-nonempty, --list)
40
41
  everystack db:check # CI gate: declared state composes + generated artifacts match regeneration
41
42
  everystack db:fingerprint # Content-address the live base schema vs the Models — MATCH/MISMATCH
42
43
  everystack db:diff # The state edge between two declared states — no DB, CI-pure
44
+ everystack db:build # Build a database FROM the models and keep it (fresh target only; --database-url, never the env)
45
+ everystack db:pull # Introspect a live DB → render field() Models: THE on-ramp for an existing database
43
46
  ```
44
47
 
48
+ **Bringing an existing database in** — `db:pull --stage <s> --abilities live --out db/models`, commit
49
+ what it renders, then `db:plan --stage <s>`. An EMPTY plan means adopted as-is (that commit declares
50
+ the database; every later `db:apply` descends from it). A NON-EMPTY plan is the hardening edge:
51
+ review it, `db:apply` it — no commit declares the database yet, so the reviewed plan is the origin.
52
+ Render from the stage, never from a local restore of a dump.
53
+
45
54
  **Authorization (the brownfield on-ramp + the red team)**
46
55
  ```bash
47
56
  everystack db:authz:pull # Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
@@ -78,6 +87,21 @@ everystack db:backup:download <id> --stage X # Presigned download URL (1h)
78
87
  everystack db:restore --from <id> --stage X --confirm # Restore a backup INTO a stage (destructive)
79
88
  ```
80
89
 
90
+ **Schema artifacts (build elsewhere, land atomically)**
91
+ ```bash
92
+ everystack db:export --schema stats --stage X # ONE schema as a fingerprint-stamped .dump (+ .meta.json)
93
+ everystack db:swap --schema stats --from <artifact> --stage X --direct --confirm # fingerprint gate → confirmed snapshot → restore → one txn → verify; destructive
94
+ ```
95
+ `db:swap`'s venue is explicit (`DATABASE_URL` in the env is refused; `--stage` needs `--direct`), and the
96
+ rollback point is confirmed before the restore begins. Full guide: docs/schema-swap.md.
97
+
98
+ **Roles & health**
99
+ ```bash
100
+ everystack db:provision --stage X # Create the least-privilege role chain (idempotent); --rotate swaps A/B credentials live
101
+ everystack db:doctor --stage X # Check the DB is least-privilege + RLS-subject (api vs operator connection)
102
+ everystack db:reset --stage dev # Drop all schemas + re-run migrations (dev only; migration-era apps)
103
+ ```
104
+
81
105
  **Local dev databases**
82
106
  ```bash
83
107
  everystack db:template:refresh # Build the dev template from declared state + seed
@@ -88,6 +112,7 @@ everystack db:fork --from-stage src --stage tgt --confirm # Fork a deployed st
88
112
  **Data**
89
113
  ```bash
90
114
  everystack db:seed --stage dev # Declarative seed script (dev only; refuses on production)
115
+ everystack db:exec fix.sql --stage X # Credential-free DML-only write, one txn; ANY schema change rolls it back (--confirm on prod-tier)
91
116
  everystack db:backfill --apply # One-shot data moves from db/backfills/*.sql (own record)
92
117
  everystack pipeline:run --stage X # Ingest pipeline (credential-free in the ops Lambda)
93
118
  everystack db:migrate # Run the generated migration on a deployed stage via Lambda invoke
@@ -104,7 +104,7 @@ const handler = createHandler(db, schema, {
104
104
  protectedFields: { profiles: ['role'] }, // Strip from writes
105
105
  rowOwnership: { posts: { column: 'authorId', userField: 'sub' } },
106
106
  hooks: { posts: { beforeCreate: async (body, user) => ({ ...body, authorId: user?.sub }) } },
107
- softDelete: { column: 'deletedAt', tables: ['posts'] },
107
+ softDelete: { column: 'deletedAt', tables: ['posts'] }, // Models: derived, don't hand-write
108
108
  maxEmbedDepth: 3,
109
109
  maxLimit: 1000,
110
110
  });
@@ -133,11 +133,28 @@ the database URL**. `db:apply` verifies → applies → verifies, and **refuses*
133
133
 
134
134
  - **the live fingerprint still equals `plan.from`** (the concurrency lock — nobody changed
135
135
  the target since you minted the plan; if they did, re-mint), AND
136
+ - **the schema's default privileges still hash to `plan.defaultAclHash`** (an
137
+ `ALTER DEFAULT PRIVILEGES` since minting changes what a created object lands with; the
138
+ refusal happens BEFORE dispatch, nothing lands — re-mint), AND
136
139
  - **your checkout descends from the commit that declares the target's state** (the
137
140
  fast-forward rule — "rebase first"; a stale checkout can't silently revert merged work).
141
+ When NO commit on any ref declares the live state — an existing database being hardened
142
+ in, or a stage hand-edited since its last apply — that is PROVEN drift over a full, current
143
+ clone, and it PASSES: the reviewed plan is the origin every later apply descends from,
144
+ recorded on the `schema_log` row. Shallow or behind-remote clones are UNVERIFIABLE and
145
+ refuse with a fetch runbook.
138
146
 
139
147
  Every refusal is recorded in `everystack.schema_log`. `--database-url` is local-dev-only;
140
- protected stages always go through `--stage`.
148
+ protected stages always go through `--stage`. Every refusal is **staleness** (re-mint, never
149
+ a flag) or **intent** (a named attestation, `--confirm`, an approver) — there is no `--force`.
150
+
151
+ The plan's `to` is the FULL endpoint: it carries the grants of roles your models do not
152
+ govern (a BI reader, an ETL role) through unchanged, so on a brownfield stage an empty plan
153
+ prints `from == to` and still names those roles and the baseline that admitted them.
154
+
155
+ **After upgrading the CLI, redeploy the stage before a staged apply.** `db:apply --stage`
156
+ is a wire contract between the CLI and the ops Lambda; a version-skewed Lambda is refused
157
+ before any write, with the redeploy named.
141
158
 
142
159
  ### 5. Destructive plans carry more gates
143
160
 
@@ -145,8 +162,15 @@ A plan is DESTRUCTIVE if it drops (table/column/type) or narrows a type (a lossy
145
162
  `SET DATA TYPE`). Those additionally require, all of them:
146
163
 
147
164
  - **`--confirm`** — always, explicitly.
148
- - **A snapshot** — taken automatically via `db:backup` when you pass `--stage` (or supply
149
- one with `--snapshot-ref`). No snapshot, no destructive apply.
165
+ - **A confirmed rollback point** — on a stage that exposes `databaseInstanceId`, a physical RDS
166
+ snapshot taken and waited to `available` before the write (the default); otherwise a logical
167
+ `db:backup` waited to completion; or one you attest with `--snapshot-ref <id>`. No rollback
168
+ point, no destructive apply.
169
+ - **The `--direct` lane.** The ops-Lambda lane (`--stage X` alone) REFUSES destructive plans
170
+ outright. Run them `--stage X --direct`: the CLI resolves the stage's operator connection
171
+ from its ops Lambda, holds it in memory only (never on argv), and runs the full ceremony
172
+ on one real connection with an unbounded clock. `--database-url` is not the venue for a
173
+ deployed stage's destruction.
150
174
  - **The stage's approver set** — if declared, `db:apply` verifies the caller's AWS identity
151
175
  (STS) against it. Set it with `db:approvers`:
152
176
  ```bash
@@ -178,11 +202,13 @@ everystack db:backups --stage production # list logical backups
178
202
  everystack db:backup:download <id> --stage production # presigned download URL (1h)
179
203
  ```
180
204
 
181
- **RDS snapshot first?** For a destructive apply, `db:apply` already forces a logical backup
182
- you cannot destroy without one. But before any large or risky migration on a stage that
183
- holds real data, take a **physical `db:snapshot` first** as well: it is instant, it captures
184
- the whole instance, and restoring it is the fastest path back if something the plan didn't
185
- foresee goes wrong. Cheap insurance; take it.
205
+ **RDS snapshot first?** A destructive `db:apply` confirms a rollback point BEFORE it writes: on
206
+ a stage exposing `databaseInstanceId` that is a physical RDS snapshot waited to `available`
207
+ (the default), otherwise a logical `db:backup` waited to completion, or one you attest with
208
+ `--snapshot-ref <id>`. The only way to destroy without one is to say so on the record with
209
+ `--snapshot none`. So a separate `db:snapshot` first is redundant on the default lane — take one
210
+ before a large or risky migration only when the stage has no `databaseInstanceId`, or when you
211
+ want a named restore point that outlives the apply.
186
212
 
187
213
  ## Importing data into a REMOTE stage
188
214
 
@@ -207,22 +233,30 @@ can't dump production. `--database-url` exists only for local dev databases you
207
233
  ## Command index
208
234
 
209
235
  ```bash
236
+ # Bring an existing database in:
237
+ everystack db:pull --stage X --abilities live --out db/models # render field() Models from live; commit; then db:plan (EMPTY = adopted)
238
+
210
239
  # Declare (edit files), then:
211
240
  everystack db:sync # dev DB ← your checkout (state + compute), one verb
212
241
  everystack db:reconcile # compute layer only (views/matviews/functions/triggers)
242
+ everystack db:refresh # repopulate the declared matviews in dependency order (credential-free)
213
243
  everystack db:generate # STATE layer → migration file / --apply / --dry-run
244
+ everystack db:build --database-url "$FRESH" # build a database FROM the models and keep it (empty target only)
214
245
  everystack db:check # CI gate: declared state composes; artifacts match
215
246
  everystack db:fingerprint # content-address live schema vs models — MATCH/MISMATCH
216
247
 
217
248
  # Reach a protected stage safely:
218
249
  everystack db:plan --stage X --out X.plan.json # mint a reviewable edge (read-only)
219
250
  everystack db:apply --plan X.plan.json --stage X # verify → apply → verify (credential-free)
251
+ everystack db:apply --plan X.plan.json --stage X --direct --confirm # a DESTRUCTIVE plan: the direct lane, full ceremony
220
252
  everystack db:approvers --stage X --set "…" # who may run destructive applies
221
253
 
222
- # Back up / restore:
254
+ # Back up / restore / move a schema:
223
255
  everystack db:snapshot --stage X # physical RDS snapshot (instant DR)
224
256
  everystack db:backup --stage X # logical pg_dump → S3
225
257
  everystack db:restore --from <id> --stage X --confirm
258
+ everystack db:export --schema stats --stage X # ONE schema as a fingerprint-stamped artifact
259
+ everystack db:swap --schema stats --from <artifact> --stage X --direct --confirm # land it atomically (destructive)
226
260
 
227
261
  # Local dev databases:
228
262
  everystack db:template:refresh # build the dev template from declared state + seed
@@ -230,6 +264,7 @@ everystack db:branch # per-branch dev DB from the template
230
264
 
231
265
  # Data:
232
266
  everystack db:seed --stage dev # declarative seed (dev only)
267
+ everystack db:exec fix.sql --stage X # credential-free DML-only write, one txn (any schema change rolls it back)
233
268
  everystack db:backfill --apply # one-shot data moves (db/backfills/*.sql)
234
269
  everystack pipeline:run --stage X # ingest pipeline (credential-free)
235
270
  everystack db:fork --from-stage prod --stage feature --confirm # copy a stage's data
@@ -254,8 +254,53 @@ Two requirements on the AWS side:
254
254
  - **Deploy IAM (`>= 0.4.3`):** dbTask writes its operator-URL SecureString to
255
255
  `/sst/<app>/<stage>/DbTaskOperatorUrl`, under the `parameter/sst/*` prefix an SST deploy role is
256
256
  already scoped to. A least-privilege deploy principal also needs ECS (cluster + task-def),
257
- ECR (auth + repo push), EC2 `Describe*` (subnets/SGs), and `logs:CreateLogGroup` for the task's
258
- log group. Override the param path with `paramName` to fit a different scoped prefix.
257
+ EC2 `Describe*` (subnets/SGs), and `logs:CreateLogGroup` for the task's log group. Override the
258
+ param path with `paramName` to fit a different scoped prefix. (ECR auth + repo push is needed
259
+ only with `build: true` — see below. The default pulls a published image and pushes nothing.)
260
+
261
+ ### The Task image — no Docker on the deploy machine
262
+
263
+ `dbTask` pulls a **published, digest-pinned** image by default, so `everystack deploy` needs no
264
+ Docker daemon. Earlier versions built the image locally on every deploy: a daemon that died
265
+ mid-run took the deploy down *after* other resources had already changed.
266
+
267
+ The image is a public GHCR package, so Fargate pulls it with no credential — nothing to rotate,
268
+ and no registry token in your AWS account. It carries no secret; the operator URL still arrives at
269
+ container start via ECS `secrets`/`valueFrom`.
270
+
271
+ ```typescript
272
+ dbTask({ sst, aws }, 'DbTask', { vpc, adminDatabaseUrl, pgMajor: 16 }); // pulls, no Docker
273
+ dbTask({ sst, aws }, 'DbTask', { …, build: true }); // builds locally
274
+ dbTask({ sst, aws }, 'DbTask', { …, image: 'my-mirror/db-task@sha256:…' }); // your own copy
275
+ ```
276
+
277
+ - **`pgMajor` selects the image.** A major with no published image is REFUSED at deploy-plan time,
278
+ before any resource is created — it never silently falls back to a local build, because a silent
279
+ fallback is how the invisible Docker dependency came back.
280
+ - **`build: true`** is the escape hatch for a custom entrypoint or an unpublished major. It needs a
281
+ Docker daemon, and it checks for one up front rather than failing at the build step.
282
+ - **`image`** takes any reference verbatim: a mirror, an air-gapped copy, a digest you pinned.
283
+ - **Pinned by digest, never by tag.** A tag can be repointed at different bytes after a deploy was
284
+ reviewed; `repo@sha256:…` is the same bytes forever.
285
+ - **`architecture` costs nothing on the pull path.** The published image is a multi-arch manifest,
286
+ so the registry resolves arm64 vs x86_64 at pull time regardless of your machine. Only
287
+ `build: true` re-couples the image to the deploy machine's architecture.
288
+
289
+ **The image is an appliance — it runs pg binaries, it does not compute policy.** A backup's
290
+ fingerprint (what the destructive-apply gate compares against `plan.from`) is computed by the CLI,
291
+ before and after the dump, and passed into the task. The task never computes one. A published,
292
+ digest-pinned image that computed it would be running CLI internals frozen at publish time while
293
+ your checkout moved on, and the skew would surface only when the gate trusted a fingerprint your
294
+ CLI would have computed differently.
295
+
296
+ Two consequences:
297
+
298
+ - **`everystack db:backup` can now refuse.** If the schema changes during the dump, the artifact is
299
+ retracted — a dump spanning a migration matches neither endpoint, so it is not a safety net.
300
+ `db:fork` and `db:swap --snapshot logical` take the same path.
301
+ - **Upgrade the CLI and the ops function together.** The ops action refuses a backup that arrives
302
+ with no fingerprint, so an older `@everystack/cli` against a newer deploy fails with a named
303
+ error telling you to upgrade. It does not silently write an unstamped artifact.
259
304
 
260
305
  **Keep `publicIp`, never `public: true`.** SST marks `publicIp` `@deprecated` in favor of `public`,
261
306
  but do NOT swap it here: `public: true` attaches an inbound security group open to `0.0.0.0/0` on
@@ -100,6 +100,7 @@ maxLimit: 1000, // Max ?limit= value (default: 10000)
100
100
  ### Other
101
101
  ```typescript
102
102
  softDelete: { column: 'deletedAt', tables: ['posts'] }, // DELETE -> UPDATE SET deletedAt
103
+ // ^ With Models this is DERIVED from each model's `softDelete: true` — do not hand-write it.
103
104
  naming: 'snake_case', // Response key format ('camelCase' default)
104
105
  ```
105
106
 
@@ -34,6 +34,9 @@ export const Post = defineModel('posts', {
34
34
  updatedAt: field.timestamptz().notNull().defaultNow(),
35
35
  deletedAt: field.timestamptz(),
36
36
  },
37
+ // Required here, not optional: this model has a `deletedAt` field AND grants DELETE, so
38
+ // `defineModel` refuses to guess. See Soft Delete below for what each value means.
39
+ softDelete: true,
37
40
  abilities: [can('read'), can('create'), can('update'), can('delete')],
38
41
  });
39
42
  ```
@@ -59,11 +62,34 @@ updatedAt: field.timestamptz().notNull().defaultNow(),
59
62
  ```
60
63
 
61
64
  ### Soft Delete
62
- Add `deletedAt` for soft-deletable tables:
65
+ The field alone does nothing. Declare BOTH — the column and what it means:
63
66
  ```typescript
64
67
  deletedAt: field.timestamptz(),
68
+ softDelete: true, // on the model, beside `fields` — NOT in the handler
65
69
  ```
66
- Configure in handler: `softDelete: { column: 'deletedAt', tables: ['posts'] }`.
70
+
71
+ **`defineModel` REFUSES to guess** when a model has `deletedAt` and either a public read or a
72
+ way to DELETE (`can('delete')`, `can('manage')` — which is "all actions" — or a `privileges`
73
+ DELETE grant). Omit the line on such a model and it throws at import.
74
+
75
+ It is declared because it changes two different things, and neither default is safe:
76
+
77
+ - **Durability, on every model** — `true` makes a `DELETE` mark `deleted_at` instead of
78
+ removing the row, and then hides marked rows from the data API's reads and updates. `false`
79
+ makes `DELETE` permanent.
80
+ - **Visibility, only where there is a public read** — `true` AND-s `deleted_at IS NULL` into
81
+ the anon policy. On an owner-scoped or admin-only model the compiled SQL is identical either
82
+ way, so the durability half is the one that matters there.
83
+
84
+ Pick `true` if the row is meant to survive its own deletion — anything with a cleanup or purge
85
+ job, an audit trail, or rows other tables reference. Pick `false` if `deleted_at` here is
86
+ audit-only and a `DELETE` really should remove the row. Getting this wrong is not cosmetic: a
87
+ table whose S3 cleanup job finds work by `deleted_at IS NOT NULL` orphans every file it should
88
+ have purged if the row is hard-deleted instead.
89
+
90
+ **Do not hand-write the handler's `softDelete: { column, tables }`.** With Models, the handler
91
+ config is DERIVED from this flag (`deriveHandlerConfig`), so writing both is how the two drift
92
+ apart — and a hand-written list silently losing a table is exactly how this bug shipped.
67
93
 
68
94
  ### Foreign Keys
69
95
  ```typescript
@@ -120,13 +146,37 @@ export const Post = defineModel('posts', {
120
146
  fields: { /* … */ },
121
147
  abilities: [
122
148
  can('read'), // anon + authenticated
123
- can('create', { role: 'authenticated' }),
124
- can('update', { role: 'authenticated', own: 'authorId' }), // row ownership
125
- can('delete', { role: 'admin' }),
149
+ can('create', { role: 'authenticated' }), // signed-in users insert rows THEY OWN — the model has an owner (below), so WITH CHECK (author_id = caller)
150
+ can('update', { owner: 'authorId' }), // row ownership: only the author, via RLS
151
+ can('manage', { role: 'admin' }), // the admin bypass: one ALL policy, true/true
126
152
  ],
127
153
  });
128
154
  ```
129
155
 
156
+ **How a write compiles — read this before naming a role.** A write ability is a GRANT plus an
157
+ RLS policy, and RLS is on for every model with abilities, so a grant with no policy is dead:
158
+ an INSERT fails loudly (`new row violates row-level security policy`), while an UPDATE or
159
+ DELETE with no policy affects **0 rows silently** — no error, nothing changes.
160
+
161
+ - `owner:` / `via:` writes compile the owner-checked policy (`<t>_insert_own`, `_update_own`,
162
+ `_delete_own`) for `authenticated`. On such a model a bare `can('create')` or
163
+ `can('create', { role: 'authenticated' })` is ALSO the owner-checked insert — the example above.
164
+ - `can('manage', { role: 'admin' })` is the admin bypass: one `ALL` policy, `true`/`true`.
165
+ - **Known gap (cli 0.4.80):** a role-scoped or bare write with NO owner on the model and no
166
+ `where:` — `can('create', { role: 'authenticated' })` on an owner-less table, `can('delete',
167
+ { role: 'admin' })`, `can('manage', { role: 'editor' })` — compiles the grant and NO policy. It
168
+ reads as access and grants none. A vacuous `where: sql\`true\`` does NOT help: the compiler
169
+ discards a `true` predicate and still emits nothing. What compiles today: `owner:` / `via:`
170
+ for row-scoped writes; `can('manage', { role: 'admin' })` for the admin bypass; and, for
171
+ `authenticated` only, a real (non-vacuous) `where:` on a `create`/`update`/`delete`, which
172
+ compiles a predicate-only policy for `authenticated`. There is no in-model spelling for a
173
+ role-wide write by any other role — and do not try a `where:` on another role's write: the
174
+ write compiler never reads the declared role, so `can('update', { role: 'editor', where })`
175
+ grants editor and lands the predicate on the **`authenticated`** policy. Run that write on the
176
+ trusted admin connection until the write-policy matrix ships.
177
+ - `check:` is the WITH CHECK half verbatim — it REPLACES the owner term, it does not narrow it —
178
+ so a `check:` on an owner model must carry the owner term itself.
179
+
130
180
  ## Handler Config for Schema
131
181
 
132
182
  The handler config is **derived** from the Models (`deriveHandlerConfig(models)`), so
@@ -80,6 +80,56 @@ GRANT admin TO authenticator;
80
80
 
81
81
  Why `NOINHERIT`: authenticator has zero privileges. All access comes through the granted roles. This prevents accidental privilege leakage.
82
82
 
83
+ ### The Two Credential Channels
84
+
85
+ There are exactly two database credentials, and they are not interchangeable.
86
+
87
+ | | Role | Used by | Never |
88
+ | --- | --- | --- | --- |
89
+ | `DATABASE_URL` | `authenticator` (least privilege) | The app plane. Every request-serving read and write, always through `withRole` / `pgSettings`. | — |
90
+ | `ADMIN_DATABASE_URL` | `migrator` (owns the schema) | The ops channel ONLY: migrate, seed, console, backup. | **Never held by request-serving code.** |
91
+
92
+ **The rule: the operator credential is the ops channel only.** It owns the schema, so RLS
93
+ does not apply to it. A request handler holding that credential has unrestricted database
94
+ access, and every RLS policy you wrote is decoration. RLS is only fail-closed once the
95
+ credentials are split — a single privileged URL means every policy fails *open*.
96
+
97
+ This is the mistake to watch for, because it does not look like one. `createAdminDb()` and
98
+ `getAdminSql()` are easy to reach from app code and will work. Working is exactly the
99
+ problem: the failure is silent and total.
100
+
101
+ #### Trusted writes: use a service role, not the operator credential
102
+
103
+ Some writes legitimately need to bypass a user's RLS scope — issuing a credential at signup,
104
+ recording a webhook, a background reconcile. **Do not reach for `ADMIN_DATABASE_URL`.** The
105
+ lawful pattern is a NOLOGIN service role, granted to `authenticator`, entered through
106
+ `withRole` *after* the handler has done its own authorization:
107
+
108
+ ```sql
109
+ -- A NOLOGIN role with exactly the privileges the trusted lane needs, and no more.
110
+ CREATE ROLE service NOLOGIN;
111
+ GRANT service TO authenticator;
112
+
113
+ GRANT SELECT, INSERT, UPDATE ON credentials TO service;
114
+ -- Give it a policy, or leave the table RLS-enabled with a policy that admits `service`.
115
+ ```
116
+
117
+ ```typescript
118
+ // The handler authorizes FIRST — the role is never derived from the token.
119
+ if (!(await mayIssueCredential(user))) throw new HttpError(403);
120
+
121
+ await withRole(sql, 'service', { sub: user.sub, role: 'service' }, async (tx) => {
122
+ await tx`INSERT INTO credentials ${tx(row)}`;
123
+ });
124
+ ```
125
+
126
+ Note that `roleFor()` returns only `anon` and `authenticated` — the roles a token may
127
+ produce. `service` is never returned from a token, so a forged or absent JWT cannot reach it.
128
+ Server code sets it explicitly, on the same least-privilege connection, after its own check.
129
+
130
+ The result: the trusted lane is scoped to the privileges that lane actually needs, and it is
131
+ still on the app plane. The operator credential stays in the ops channel where it belongs.
132
+
83
133
  ### Table Grants
84
134
 
85
135
  ```sql
@@ -260,6 +310,12 @@ RESET ROLE;
260
310
  - [ ] ENABLE + FORCE ROW LEVEL SECURITY on all public tables
261
311
  - [ ] Write RLS policies per table x role
262
312
  - [ ] Connect as `authenticator` (never use RDS master/superuser in production)
313
+ - [ ] Split the credentials: `DATABASE_URL` = authenticator, `ADMIN_DATABASE_URL` = migrator
314
+ - [ ] No request-serving code holds the operator credential — grep the server for
315
+ `createAdminDb` / `getAdminSql` and confirm every hit is an ops path (migrate, seed,
316
+ console, backup), not a request handler
317
+ - [ ] Trusted writes go through a NOLOGIN service role via `withRole`, not the operator
318
+ credential
263
319
  - [ ] Test: SET ROLE authenticated; SELECT returns only expected rows
264
320
  - [ ] Test: a 500 returns only `error` and `requestId` — no relation or column names
265
321