Plutus v1.0.0
[1.0.0] — 2026-06-27
Plutus 1.0 — the billing loop is closed and the contract is frozen. The
ledger is now an auditable mirror of Stripe (refunds, disputes, and failed
payments reverse it idempotently); every money- and quota-bearing input is
guarded; ingest and auth are hardened; self-serve export and a token-scoped admin
API are in; and the /v1 OpenAPI spec plus the DB forward-compatibility policy
are published as the frozen contract Perseus and Perseus Vault build against. An internal
security review (documented in docs/security-review-2026-06-27.md) cleared the
money/auth/tenant surfaces; an external review remains the gate before any public
launch.
Fixed
- Orphaned in-flight Idempotency-Key no longer 409s forever (review F3, #80).
If a request crashed between claiming anIdempotency-Keyand storing its
response, the row stayedstatus=NULLand every retry got409permanently.
A claimed-but-unanswered row older than a 2-minute grace window is now
reclaimable (re-processed), while a completed claim is never reclaimed
(replay preserved). Addeddb.purge_idempotency()to bound the table.
Added
- OpenAPI 3.1 spec for
/v1/*+ the forward-compatibility contract (#67).
openapi.yamldocuments the frozen/v1surface (usage ingest,
spend export, admin) that Perseus/Perseus Vault build against.docs/schema.md
states the database forward-compat policy (additive-only within 1.x; breaking
changes need a new major), anddocs/postgres.mdrecords the
ADR keeping the single-file SQLite backend for 1.0 while documenting the
Postgres migration shape.db.SCHEMA_VERSIONbumped to 5 (the
ingest_idempotencytable);init_schemanow refuses to open a database
written by a newer Plutus, anddb.get_schema_version()reads the stamped
version.
Security
- Negative token counts can no longer rewind the free-tier meter (#80). The
/v1/usageboundary validated only that token fields coerce to int, so a
negativeinput_tokens(with a non-negativecost_usd, dodging the #61 guard)
rewoundtracked_tokens_mtd— bypassing the Free-tier quota — and corrupted
SUM(tokens)aggregates. Negatives are now rejected with a400at the
boundary and aValueErrorinrecord_usage. - CSRF synchronizer token as defense-in-depth (#58). State-changing
dashboard POSTs now accept a per-session CSRF token in addition to the existing
fail-closed Origin/Referer check: a request passes if it is same-origin or
carries a valid token. This lets through legitimate requests whose
Origin/Referer a privacy proxy stripped (which the origin check rejects), while
a forgery — which can't know the token — is still blocked. The token is
HMAC-SHA256(session_token, "plutus-csrf-v1"), derivable only by the cookie
holder and never leaking the cookie; it's embedded as a hidden_csrffield in
every dashboard/pricing form. The origin check remains the first gate. - Per-IP self-serve signup throttle (#59). The existing global hourly limiter
and DB-backed daily org cap (#33) are both global, so one abuser could drain
the whole daily budget and lock out legitimate signups. A new per-IP cap
(auth.max_signups_per_ip_per_day, default 3; in-memory 24h ring) is checked
before the global limiter. The client IP is the socket peer by default, or the
firstX-Forwarded-Forhop whenauth.trust_forwarded_foris set (for running
behind a trusted reverse proxy). Existing members signing in are never
throttled — only new-org self-serve signups.
Fixed
- Dashboard "Sign out" chip used an undefined
--mutedCSS var (#56). The
signed-in user chip and its Sign-out button now usevar(--dim), so the text
renders in the intended dim gray instead of falling back to the inherited color.
Changed
- Package version is single-sourced (#57).
pyproject.tomlnow declares
dynamic = ["version"]reading fromplutus_agent.__version__, so the wheel
metadata andplutus versioncan no longer drift apart.
Tests
- High-risk auth/tenant coverage (#66, part 3 — closes #66). Added tests for
the previously-untested money/auth paths: the hand-rolled OIDC RS256 verifier
itself (a real pure-Python RSA-signed token verifies; a tampered payload and a
non-RS256algare rejected — every other auth test had set
allow_unsigned_tokens, so the signature math was never exercised); the
_authz_orgcross-tenantPermissionErrorpath; and the
allow_negative_balanceexemption end-to-end over HTTP. (The #60/#61/#62
coverage landed with those fixes.)
Added
- Token-scoped admin API (#66, part 2). A new
/v1/admin/*surface lets an
operator script tenant management instead of using the CLI/dashboard only:
GET/POST /v1/admin/orgs(list / create),POST /v1/admin/credits
(grant/adjustledger entries), andGET/POST /v1/admin/keys(list /
mint — the secret is returned once). Gated by a singleadmin.token
(envPLUTUS_ADMIN_TOKEN, masked from saved config, constant-time compared);
with no token configured the API is disabled and returns404. - Self-serve spend export + cursor pagination (#66, part 1). New
GET /v1/usage/export.csvandexport.json(Bearer-authenticated, org-scoped,
optional?since/?untilepoch bounds) let a customer pull their own usage
for their books. List endpoints now paginate with a?limit&before=<_rowid>
cursor: newGET /api/ledgerandGET /api/eventsreturn{items, next_before, limit}, andGET /api/orgsaccepts?limit&offset. The
underlyingdb.ledger_history/metering.recent_eventsgained abefore
cursor.
Security
/v1/usageingest hardening (#65).- Idempotency-Key. A retried or duplicated POST used to double-count usage
and double-debit credit (the inverse of the webhook idempotency from #26).
The endpoint now accepts anIdempotency-Keyheader, claims it atomically
with the recording (per-orgingest_idempotencytable), and replays the
stored response on a duplicate instead of re-recording. - Per-key rate limit. A leaked/abusive key could fire unbounded batches; a
per-key token-bucket limiter (configingest.rate_per_min/burst) now
returns429when exceeded. - Monitor-bridge lock-down. The bridge subprocess now requires the command
to be an absolute path present inmonitor.allowed_binaries(fail-closed,
structured argv,shell=False), and when auth is on it only shells out for
an authenticated request — an unauthenticated dashboard hit no longer
triggers it.
- Idempotency-Key. A retried or duplicated POST used to double-count usage
Added
- Estimated costs are flagged
unpricedwhen no exact model price exists
(#64). Whenever a usage event is metered without an exactcost_usdand the
(provider, model) isn't in the price table, the cost falls back to a
provider/global default — previously with no signal, so a coarse estimate
looked authoritative.MeterResult.unpricednow carries that signal and it is
surfaced per-event in the/v1/usageresponse. The price table is expanded to
current 2026 models (addsclaude-fable-5, the GPT-5 family, more Gemini, and
xAI / Mistral / Cohere / Meta providers), carries a datedPRICE_TABLE_AS_OF
stamp shown on the pricing page, andModelPricecan now price reasoning
tokens separately (defaults to the output rate, so existing estimates are
unchanged). Deferred: persistingunpricedonto historical dashboard rows
(needs ausage_eventscolumn) and cache-write token pricing (needs a new
event token field) — both noted for a follow-up.
Fixed
- Money-correctness cluster (#63) — four independent fixes:
- USD-only is enforced. The credit ledger stores plain USD micro-dollars
with no currency dimension, so a non-USD top-up was recorded as the wrong
number of dollars. A configuredbilling.currencyother thanusdnow
raises a clearBillingErrorinstead of silently mis-billing. past_dueno longer counts as active Pro. A subscription in dunning
used to retain full Pro for the whole retry window; Pro is now kept only
throughactive/trialing(Stripe restores Pro on the nextactive).- Credit checkout amounts are bounded to a finite $1–$10,000 at the form
boundary —inf/nan/a 9-figure typo previously passed straight to Stripe. - Month boundaries are computed in UTC, matching the UTC-epoch event store,
so the free-tier quota reset and MTD reports no longer shift by the server's
UTC offset on a non-UTC host.
- USD-only is enforced. The credit ledger stores plain USD micro-dollars
- Batch
/v1/usageno longer hides prepaid-hard-stop rejections (#62). The
multi-event summary reported only the free-tierblockedcount;
over_balancerejections were absent, so a prepaid org past zero credit could
get a200with events silently dropped. The summary now carries
over_balance_blocked,free_limit_blocked, and ablockedtotal covering
both reasons, and the endpoint returns402whenever nothing landed —
including a batch split across both rejection reasons (previously it only 402'd
when a single reason accounted for the whole batch).
Added
- Stripe refunds, disputes, and failed payments now reverse the ledger
(#60). The webhook handler previously ignored every reversal event, so a
refunded or charged-back prepaid top-up left the credit on Plutus's
append-only ledger forever. New handlers:charge.refundedposts a negative
refundentry (converging to the charge's cumulativeamount_refunded, so
partial/repeat refunds reverse exactly once);charge.dispute.created/
charge.dispute.funds_withdrawnpost a negativeadjustfor the disputed
amount (both events for one dispute converge to a single reversal); and
invoice.payment_failedis recorded as a dunning alert. Top-ups are now keyed
on the PaymentIntent so a dispute (which carries no customer) maps back to its
org. Reversals converge to a target amount per Stripe reference on top of the
existing per-event idempotency, so replays can't double-reverse.
Security
- Negative
cost_usdcan no longer mint prepaid credit or bypass the hard-stop
(#61). A caller-supplied negativecost_usdpreviously flowed to the ledger
debit path as-(-x)— a positive credit delta — and slipped past the prepaid
hard-stop (a negative cost only raises the projected balance).record_usage
now rejects a negativecost_usdwith aValueError, and/v1/usagereturns
400for a negative or non-numericcost_usdbefore any event is recorded.
Genuine corrections/credits must go through the explicit adjust/grant/refund
ledger path, never metering.
Changed
- Prepaid credit hard-stop is now ON by default (#28).
pricing .block_over_balancedefaults totrue, so a prepaid org can no longer debit
unbounded amounts past a zero balance —/v1/usagereturns402with
over_balanceonce a charge would go negative. It only affects orgs that have
actually held credit; pure free-tier tracking is never blocked. Trusted /
internal orgs can opt into track-only mode with a new per-org
allow_negative_balanceflag, toggled viaplutus org allow-negative <org>/
plutus org enforce-balance <org>(idempotent column migration on existing
databases).
Fixed — 1.0 punch-list (#37)
org create/workspace createwith no NAME now exit with a usage
message instead of crashing inslugify(None).- 500 responses no longer leak
str(exception)— both the GET error page and
the POST JSON return a generic message plus a short reference id; the full
exception is logged server-side under that id. - Reflected-XSS surface closed — the 404 handler now HTML-escapes the
request path before rendering it. - Ambiguous-org guard — state-changing POSTs (billing, API keys) require an
explicitorgwhen the signed-in user belongs to more than one, instead of
silently acting on the earliest org. Dashboard GETs stay lenient. api_key_orgthrottleslast_used_atto at most once per 60s per key,
removing per-ingest WAL thrash / write contention with the metering txn.install-claude-hookbackup copies the pristine original bytes once and no
longer clobbers that backup (or re-serializes away comments) on re-runs.- PyYAML-free config reader now reads back the block-style lists PyYAML
writes, so a config saved with PyYAML and re-read without it no longer silently
resets to defaults.
Security
- DB-backed per-day signup cap (#33) — self-serve org creation now has a
hard ceiling per rolling 24h (auth.max_new_orgs_per_day, default 50),
counted from theorganizationstable so it survives process restarts —
unlike the existing in-memory hourly limiter, which it complements. Set to
0to disable. - OIDC unsigned-token bypass removed — signature verification was skipped for
any id_token whose header segment literally equalled"hdr"(a test shim) on
the production path. It is now gated behind an explicit, default-off
auth.allow_unsigned_tokensflag used only by the test suite. (#37)