v0.3.7: five bugs, four of them silent
satus 0.3.7 fixes identity columns, foreign keys to non-PK columns, a TRUNCATE that reached outside the run set, two cost numbers that disagreed, and telemetry that broke our privacy promise.
@passkeybridge/satus@0.3.7 is on npm. Five fixes. Four of the five failed silently — the run printed ✓ inserted N rows and the damage surfaced later, somewhere else, usually in your application rather than in ours.
That is the part worth dwelling on. A seeder that crashes is annoying. A seeder that reports success and leaves your sequences broken or your foreign keys unlinked is worse, because you find out days later and the first suspect is your own code.
Identity columns were handed to the model
Postgres reports a GENERATED ... AS IDENTITY column oddly. Its column_default is NULL and its is_generated is 'NEVER' — because is_generated refers to GENERATED ALWAYS AS (expr) STORED, which is a different feature. Identity lives in is_identity, a column we were not reading.
So satus saw a plain NOT NULL integer with no default and asked the model to invent one. Two outcomes, depending on which flavour you declared:
create table a (id int generated always as identity primary key);
-- ERROR: cannot insert a non-DEFAULT value into column "id"
-- DETAIL: Column "id" is an identity column defined as GENERATED ALWAYS.
The whole run is one transaction, so this rolls back everything. A schema using GENERATED ALWAYS AS IDENTITY — the form Postgres's own docs recommend — could not be seeded by satus at all.
The BY DEFAULT flavour is the one that scared us:
create table b (id int generated by default as identity primary key);
insert into b (id) values (1); -- accepted; sequence still at 1
insert into b (id) values (2); -- accepted; sequence still at 1
insert into b default values; -- id = 1 → duplicate key
Postgres takes the value you give it and does not advance the sequence. satus reported success. Your application's next insert collided with a seeded row, and the stack trace pointed at your code.
Introspection now reads is_identity, and identity columns are omitted from both the JSON schema the model fills and the INSERT column list, so the sequence allocates normally. Verified against PostgreSQL 16.13 in both flavours.
Foreign keys to non-primary-key columns became NULL
INSERT ... RETURNING named only primary-key columns. That is fine when every foreign key points at a primary key, which is the textbook case and the case our fixtures covered.
It is not the only case. orders.user_email -> users.email, with a UNIQUE constraint on users.email, is ordinary. For that shape the pooled parent row had no email key at all. Reading it gave undefined; the writer's serialize() mapped undefined to null; and because the child column was nullable, Postgres accepted the row. Every subscription inserted with user_email = NULL and nothing anywhere raised.
The writer now returns every column that some foreign key in the run set references, not just primary keys. And if a reference still cannot be resolved, satus raises with the table, column, and what it did find, rather than falling through to NULL. A crash you can debug beats a NULL you never notice.
--truncate emptied tables outside the run set
--truncate issued TRUNCATE ... RESTART IDENTITY CASCADE. CASCADE follows foreign keys wherever they go — including into tables you deliberately put in exclude.
NOTICE: truncate cascades to table "audit_log"
A NOTICE. That is the entire warning you got before your audit table was emptied.
CASCADE is gone. Every table in the run set is named in one statement, which satisfies Postgres whenever the foreign-key graph is closed over that set — the ordinary case, and the one where --truncate did what you wanted anyway. When it is not closed, satus now stops:
error: --truncate cannot run: a table outside the run set has a foreign key into it.
Table "audit_log" references "tenants".
satus will not TRUNCATE ... CASCADE, because that would also empty a table you did not ask it to seed.
Either bring the referencing table into the run set (remove it from "exclude" in satus.config.json), or truncate it yourself first.
Exit code 1, nothing deleted. This is a behaviour change: a run that previously succeeded by cascading now fails. That is the point. If you were relying on the cascade, widen the run set or truncate those tables yourself.
We have also corrected an earlier post that described the old cascade behaviour approvingly.
The two cost numbers never agreed
satus generate --dry-run prints an estimate. A real run prints what it spent. They came from different price tables.
The estimator hardcoded gpt-4o-mini's $0.15/$0.60 per million tokens and never looked at --provider. So an Anthropic run was estimated at OpenAI's cheapest rate and metered at Anthropic's, and no amount of arithmetic reconciled the two figures.
Underneath that, the Anthropic price table had been shipped empty since v0.3.0 with a // populate in Pass 4 comment that never got its Pass 4. Every Anthropic run fell through to a fallback of $3/$15 — exactly 3x the real rate for the default model, claude-haiku-4-5, at $1/$5. Anthropic runs over-reported spend threefold, which meant --max-cost aborted runs that were nowhere near the cap.
Rates now live on the provider object, so the estimator and the live meter read the same numbers by construction rather than by anyone remembering to keep two files in sync. The Anthropic table is populated and dated.
Both fallbacks are now the most expensive entry in their own table. An unpriced model can therefore only make --max-cost abort early, never overshoot silently. OpenAI's old fallback of $1/$3 sat below three of its four priced models, which is under-billing — the wrong direction for something whose job is to stop you spending money.
Telemetry contradicted our own privacy promise
This one is on us in a different way, because we had written the rule down and then not followed it.
The CLI README and our privacy policy both say satus sends "no table or column names, no row data". The run telemetry payload carried:
tables— every table name in the runtarget_schema— the schema name, which in a multi-tenant setup is itself a customer identifiererror_message— the raw error text
The third is the serious one. Postgres unique-violation messages embed the offending value:
duplicate key value violates unique constraint "users_email_key"
Key (email)=(ada@example.com) already exists.
A column name and a row value, posted to us, in a payload documented as containing neither.
We had a choice: amend the policy to describe what we actually collected, or change the code to match what we had promised. We changed the code. The promise is a better product than the data.
tables is replaced by table_count. target_schema is gone. error_message is replaced by error_class, a fixed vocabulary — pg_23505, provider_http_429, budget_exceeded, no_parent_rows — computed from SQLSTATE codes and HTTP statuses, never from message text. Anything unrecognised classifies as unknown rather than a truncated string, because a truncated Postgres error is exactly where a row value survives.
Here is the entire payload for a successful run now:
{"id":"7526b01a-…","cli_version":"0.3.7","environment":"dev","profile":"saas",
"provider":"anthropic","model":"claude-haiku-4-5","table_count":4,"status":"success",
"total_rows":8,"total_cost_usd":0.0112,"input_tokens":3200,"output_tokens":1600,
"duration_ms":102}
And for a run that failed on a check constraint: the same fields, plus "error_class":"pg_23514".
Older CLIs are already installed and will keep sending the old fields for as long as people run them. So /api/public/cli/run now accepts those three keys — rejecting them would break clients built against a contract we published — and discards them before the insert. The promise holds regardless of which version you have installed, not just for people who upgrade.
For the record: the only runs ever recorded under the old behaviour were our own release tests. No customer data was collected. That is luck of timing, not design, and it is not a reason the bug mattered less.
Tests
Each fix ships with a regression test that names the bug, and each test was mutation-checked — we reverted the fix and confirmed the suite went red. A test that passes against the broken code is not a regression test, it is a comment.
satus.config.json from 0.3.x works unchanged. No flags added, removed, or renamed.