zeekayandHanzo Dev 3a29955a00 account: a service account is a machine, so it spends the pool
IAM spells a machine two ways and money read only one. "application" is the
OIDC client shape; "service-account" is the identity class IAM's own
provisioning mints -- internal/oidc/provision.go writes it for a tenant's
pk-/sk- credential, /v1/iam/service-accounts creates rows of it, and SCIM
declares userType readOnly because it is that discriminator.

IsMachine matched only the first, so every service account provisioned into
the signup org fell to the personal branch and addressed a wallet no funding
path can name. Measured on hanzo/guest, the anonymous chat free tier: it
reads $0 while the org pool beside it holds ~$149k, and every priced call
answers 402 insufficient_balance -- text, speech and transcription alike.

This is the API-key half of the defect IAM already fixed for tokens (a
machine credential names its payer): there, the cure was a signed claim; on
the sk- path there is no claim to read, so the shape rule has to know both
spellings.

Writing Type needs org-admin, SuperAdmin, or an app holding CapUserAdmin, so
neither spelling is reachable by a plain member -- adding the second widens
nothing the first did not already admit.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-08 06:53:54 -07:00

account

The billing-account rule. One value, one decision, no dependencies.

account.Payer(account.Credential{Owner: org, Name: user, Account: claim}).Subject()

(Org, Subject) is the money's address — Org names the ledger that holds it, Subject the key within that ledger. A deposit credits that address, the spend-gate reads it, a usage debit spends it. One address, one answer, every caller.

Payer is the whole point: it, and only it, decides whether a request pays from an org pool or a person's own account. That decision shipped wrong twice by being made in two places — a gate keyed on the pool while the debit spent a person's wallet, so a funded pool green-lit a request that drained an empty personal one, and an empty pool 402'd a funded person. Any new money path resolves its address here rather than re-deriving it.

Why it is its own module

It has zero dependencies — go.mod is two lines and the code imports only strings. That is what lets every layer import it: cloud's trust boundary (clients/principal), its metering middleware, and commerce's billing resolver all call the same rule without any of them depending on each other.

Folding it into the commerce service would invert that: identity code would have to import a ledger, a SQLite store and an HTTP surface to answer a pure question about who pays.

Layout

  • account.go — Account, Org/Person/Project, Parse, Payer, Credential
  • org.go — org-level helpers

Licensed under MIT OR Apache-2.0, per HIP-0137.

S
Description
pull mirror of github.com/hanzoai/account
Readme
166 KiB
Languages
Go 100%