Skip to content

Latest commit

 

History

562 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

platform-go

Go Reference codecov

A Go library of the things a product has: a noun with a table, its lifecycle, its transport, its permissions and its privacy obligations. Identity, billing, audit, webhooks, sagas and the rest ship a Store, the DDL for the dialects they serve, a mock, and — for the ones that have crossed — a gRPC or HTTP surface over them. The infrastructure they are built from is primitives-go, which this module requires; Primitives and Domains is the rule that says which is which.

Module: github.com/primandproper/platform-go/v14 Go: 1.27

Project Status & Stability

main is not a release channel. Anything on main that has not been cut into a tagged release is considered under active development — alpha/beta, unstable, and unsupported. Treat it as such.

This repository follows a deliberately conservative release model:

  • Only tagged releases are supported. If it isn't behind a version tag, it can change or break without notice, and no support or compatibility is promised for it.
  • main moves ahead of the latest release. New work — including breaking changes — lands on main well before it is deemed release-worthy. Two facts locate you at any moment, and both are derived rather than written down here: the module path in go.mod is the major that main is currently building toward, and the highest version tag is the latest supported release. Whatever is on main but not yet in that tag is subject to change — and immediately after a major bump, that is the entire major.
  • Semantic Versioning, enforced by Go's module paths. Breaking changes increment the major version and the module import path (/vN → /vN+1), so a major bump can never silently break a consumer that hasn't opted in. The path bump lands in the same change that makes the break, never as a follow-up, which is why main's major is frequently one ahead of anything you can fetch by tag.
  • No stability guarantees on unreleased APIs. Interfaces, config shapes, and package boundaries on main are subject to change until they ship in a release.

If you depend on this library, pin to a released tag — and note that @latest against a major that has no tag yet resolves to a commit on main rather than to a release. If you want to track upcoming work, main is fair game — just don't expect it to hold still.

Installation

go get github.com/primandproper/platform-go/v14@latest

Because breaking changes ride the major-version import path, upgrading across majors is an explicit, opt-in edit to your import paths — never a surprise from go get -u.

Design Patterns

Store, DDL, mock. A package that owns data ships a Store interface, a SQL implementation of it, the DDL for whichever dialects the matrix below grants it, and a moq-generated mock. Its statements are rendered into a checked corpus and executed through a generated querier, so a column renamed in a migration is a failed generate rather than a runtime scan error.

A write takes a Tx; a read takes an executor. Every exported store write reads (ctx, tx database.Tx, scope tenancy.Scope, ...) and every exported read reads (ctx, q database.SQLQueryExecutor, scope tenancy.Scope, ...). A Tx is producible only inside Client.WithTransaction, so the write's signature is a compile-time claim that the caller is already in a transaction — which is the point, because a consumer's write almost never travels alone: the row, its audit entry and its outbox event are one fact. The read takes the wider type so that a caller inside a transaction sees its own uncommitted writes.

Tenancy is a column, not a convention. A component that stores consumer data stores it for somebody, and that somebody is a tenancy.Scope — an opaque owner identifier, deliberately not a string, so a scopeless call fails to compile rather than matching everything. tenancy.Global() is the scope of data belonging to no tenant, so a single-tenant application passes it everywhere and behaves exactly as it did before. Scope in the column, scope in the query, and no read path that omits it.

Config subpackages. Each package has a config subpackage with env:-tagged structs and ValidateWithContext() (via go-ozzo/ozzo-validation), and logger/tracerProvider/metricsProvider arrive as WithX options rather than positionally. Absent means noop: a caller that wants no observability names none of it. An unrecognized provider name returns errors.ErrUnknownProvider rather than a working-looking noop, because a typo that silently discards every message is a production incident that looks like a healthy process.

OpenTelemetry throughout. Every store, transport and worker here instruments through primitives-go's observability, whose logging, tracing, metrics and profiling pillars a consumer supplies once and threads everywhere.

Error handling. Uses cockroachdb/errors for rich, wrapped error context, over the sentinels primitives-go's errors package defines, conventionally imported as platformerrors. Its errors/http and errors/grpc map the primitives and cannot import the tier above them, so everything here maps itself: audit, authentication/oauth2clients, authentication/passkeys, authentication/passwordreset, authentication/signin, billing, comments, dataprivacy, entitlements, identity, issuereports, links, mediaregistry, metering, notifications, operations, sessions, settings, shredding, waitlists and webhooks each export an HTTPMapper and a GRPCMapper beside their sentinels. The composition root registers all of them in one call — errormappers.Register(), which service.Register makes for a service built from a service.Config and a service assembled by hand makes itself. operations/http.New is the single exception, registering its own HTTP mapper because it was the only surface here that both answered through errors/http and belonged to a package on that list; dataprivacy/http is a second one now and deliberately did not follow it, because one door stays one door. internal/sentinelmatrix checks that every exported sentinel in those packages has a decision recorded and that it still holds on both transports.

Package Catalog

This module is the domain tier: a noun with a table, its lifecycle, its transport, its permissions and its privacy obligations. The providers behind an interface — cache, database, email, messagequeue, observability, routing, secrets, search and the rest of what every service is built from — are primitives-go, and its README catalogues them. Primitives and Domains is the rule that sorts a new package into one or the other.

Implementations are listed in parentheses. Where an implementation is a SQL dialect, SQL Dialect Support is the full matrix.

Identity & access

Package Purpose Implementations
identity Users, accounts, memberships and invitations, the lifecycle over them, and identity/privacy, the directory's contribution to a subject access request postgres, mysql, sqlite (+ grpc)
authentication/signin Sign-in: the order the engines and the directory are used in, owning no table of its own — (+ grpc)
authentication/signin/refreshtokens The refresh tokens sign-in rotates: digest at rest, single use, grouped into one family per login postgres, mysql, sqlite
authentication/signin/magiclinks The sign-in links the passwordless door mails: digest at rest, single use, and a redemption that proves the address it was sent to postgres, mysql, sqlite
authentication/signin/recoverycodes The recovery codes a person keeps on paper for a lost authenticator: digest at rest, single use, spent by the door they prove, and authentication/signin/recoverycodes/privacy postgres, mysql, sqlite
authentication/phonecodes Short codes texted to a person who is not a user: digest at rest, single use, dead after too many wrong guesses, one live code per number, and authentication/phonecodes/privacy postgres, mysql, sqlite
authentication/passwordreset Password reset tokens and the flow that spends them: digest at rest, single use enforced by the store, redemption and password change in one transaction, and authentication/passwordreset/privacy postgres, mysql, sqlite
authentication/webauthnsessions Passkey ceremony state that outlives one replica postgres, mysql, sqlite
authentication/passkeys The credentials a passkey registration produces, the sign count clone detection compares against, the Service that runs registration and named and discoverable login over them (seams: UserResolver, UsernameResolver, EnrollmentGate, AlternativeSignIn, Hooks), and authentication/passkeys/privacy postgres, mysql, sqlite (+ grpc)
authentication/oauth2clients An administered OAuth2 client registry, and authentication/oauth2clients/privacy postgres, mysql, sqlite (+ grpc)
authentication/grants The tokens a third party granted this deployment, per subject per provider: sealed at rest, refreshed by compare-and-set, revocable from either side, and authentication/grants/privacy postgres, mysql, sqlite
authentication/oauth2serverstore The OAuth2 server's client and token tables postgres, mysql, sqlite
rbac Roles and permissions as rows, behind the policy interface postgres, mysql, sqlite
sessions Server-side sessions over cookies cache, database (+ http)
callers Who is calling: the interface, its extractor, and the refusal they share —

Product & commerce

Package Purpose Implementations
billing What a deployment sells, and what its customers paid: catalog, subscriptions, purchases, ledger postgres, mysql, sqlite
entitlements Feature access & remaining quota —
metering Durable usage metering & quotas. Ships no privacy adapter, and its doc records the ruling postgres, mysql, sqlite
settings Per-user and per-account runtime settings: definitions, per-subject values, and settings/privacy postgres, mysql, sqlite
comments Threaded comments on consumer-declared targets postgres, mysql, sqlite
issuereports User-submitted issue reports with a triage lifecycle postgres, mysql, sqlite
waitlists Pre-launch waitlists: signup lifecycle, and an unsubscribe that outlives the address postgres, mysql, sqlite
series Standing appointments: a weekly rule, its occurrences written ahead, and their skips, moves and make-ups postgres, mysql, sqlite
links Opaque, expiring, single-use action links postgres, mysql, sqlite

Records, privacy & retention

Package Purpose Implementations
audit Tamper-evident audit log postgres, mysql, sqlite (+ grpc)
dataprivacy Subject access & erasure requests postgres, mysql, sqlite
shredding Per-subject data keys that can be destroyed postgres, mysql, sqlite
retention Policy-driven expiry deletion postgres, mysql, sqlite
mediaregistry Object metadata rows over an object store, and mediaregistry/privacy postgres, mysql, sqlite

Coordination & delivery

Package Purpose Implementations
outbox Transactional outbox postgres, mysql, sqlite
workqueue Leased work queue (SKIP LOCKED claim/complete/expire) postgres, mysql, sqlite
timers Durable one-shot scheduling (run once at time T, fleet-wide) postgres, mysql, sqlite
operations Long-running operations with durable state, two-tier progress, and streamed updates postgres, mysql, sqlite (+ http)
saga Linear durable sagas with compensations postgres, mysql, sqlite
webhooks Outbound webhook delivery postgres, mysql, sqlite
notifications The in-app inbox, the device registry, and the notifications/push fan-out postgres, mysql, sqlite (+ grpc)
searchsync Reindexing worker driven by the outbox —

The composition root

Package Purpose
service One walk of one config that wires both modules
errormappers The one call that tells the two transport registries what these sentinels mean
privacyadapters The one call that puts every privacy adapter this module ships in a dataprivacy.Registry

The promises

Package Purpose
conformance This module's behavioural promises, written once and assertable against a subject that is either this module's own assembly or a consumer's running service

A surface's own package asserts what its handler decides, over a server that package built. conformance asserts what a client sees, over a server somebody else built — which is the half that was only ever checked in a consumer's repository, and therefore the half this module could break and learn about from somebody else's CI. See the package documentation for the three subjects and what each one proves.

Primitives and Domains

There were two kinds of package here and they have separated: the primitives left for primitives-go, and what stays is the domain tier. The rule that sorted them is the one to check a new package against before writing it, because it now decides which of the two repositories the package is written for, and it is one property — does the package own a table, or drive one.

primitives-go ships what every service is built from and no service is. Four kinds of thing qualify: a provider behind an interface (cache, email, messagequeue, ...); a transport whose shape is decided by something other than the consumer's domain (a probe, a protocol, a middleware contract, a third party's payload); the database and schema tooling stores are built with (database and its subpackages, filtering); and the cross-cutting values both tiers have to agree on (tenancy.Scope, the errors sentinels, clock). Nothing in it owns a table.

platform-go ships what a product has: a noun with a table, its lifecycle, its transport, its permissions and its privacy obligations. The test for a new package is whether an application with no users would still need it. If yes, it is a primitive.

The table below is this module's side of that sort. It is not the whole rule's answer and is not meant to be: what the primitives are is primitives-go's README to list, and a copy of that list here would be a second answer with nothing checking it.

What it is Packages
a noun with a table, and what it owes audit, authentication/grants, authentication/oauth2clients, authentication/oauth2serverstore, authentication/passkeys, authentication/passwordreset, authentication/phonecodes, authentication/webauthnsessions, billing, comments, dataprivacy, entitlements, identity, issuereports, links, mediaregistry, metering, notifications, operations, outbox, rbac, retention, saga, searchsync, series, sessions, settings, shredding, timers, waitlists, webhooks, workqueue
a domain flow over another domain's tables authentication/signin
this module's promises about its own surfaces conformance
the vocabulary a domain transport shares callers
the composition root that registers both tiers errormappers, privacyadapters, service

The second row is the one the rule's own wording anticipates when it asks whether a package owns a table or drives one. authentication/signin owns no schema: it is the order the engines and the directory are used in — read the handle, compare the hash, check the status, ask for the code, mint the token — and every row it touches is identity's. It is still emphatically the domain tier, because an application with no users has nobody to sign in, and because the refusals it collapses are a product decision rather than a mechanism. A package like it is the shape to expect as more domains arrive: the flows over the nouns, after the nouns.

It gained three tables without becoming one, and the split is where the sentence stays true. Refresh-token rotation needs rows — a digest, a family, a deadline — so those live in authentication/signin/refreshtokens; the passwordless door needs rows of its own for the same reason, so those live in authentication/signin/magiclinks; and the recovery codes that stand in for a lost authenticator are a set of single-use rows too, so those live in authentication/signin/recoverycodes. Each is a noun with a table like any other, while the flow above them holds the seams rather than the schemas. None of the three is a row of its own in the tier sort for the reason links/database and sessions/database are not: a nested package inherits its parent's tier by longest-prefix match, so it is classified by construction and a row would be a second answer with nothing checking it. A service that names none of the stores still signs people in with a password and still owns nothing, which is what keeps this row's claim about signin itself rather than about everything under its path.

The third row is the newer shape and it arrives for a different reason. callers owns no table either, and it is not a flow: it is the interface a consumer's authentication interceptor satisfies, the function that reads one off a context, the refusal an authorizer returns, and the optional second identity an operator acting as somebody else fills — names that every gRPC surface here reads and no two of them may disagree about. It is a domain because a principal is a user, the directory they are in and the account their request is against, and an application with no users has nobody to extract. It is a package of its own because it was declared in identity/grpc until /v14, so a consumer wiring only settings, or only comments, linked the directory, its generated querier, its migrations and its protobuf bindings in order to compile an interface with three methods on it. What makes that stay fixed is a test rather than this paragraph: callers imports nothing else in this module, and the only gRPC surface here that still reaches identity is authentication/signin/grpc, which renders a signed-in user and says so where the test can read it.

Eight of the paths above sit under a directory this module does not own the root of, and every one of them is under authentication/. Seven are a primitive with a store nested inside it — authentication hashes passwords and issues tokens in primitives-go, and authentication/passwordreset owns a table of them, as authentication/phonecodes owns a table of the codes texted to a person who is not a user; authentication/oauth2clients, authentication/oauth2serverstore, authentication/webauthnsessions and authentication/passkeys split the same way, the last two against one engine: the ceremony a login runs and the credential that ceremony produced are two tables, and the protocol between them is a primitive. authentication/grants is the same split facing the other way: the two OAuth2 packages are this deployment as the authorization server, and it is this deployment as the client, holding the tokens somebody else's server issued — the protocol is golang.org/x/oauth2's and the table is here. The eighth is authentication/signin, which is neither: it is a domain flow under a primitive's path, there because sign-in is what those engines are for and a signin at the root would hide that.

authentication/ is the one straddle parent that groups rather than indirects — eight related domain packages under a name a reader wants — which is why it is the one that stayed. Go is content with a parent directory holding no .go files, and six of them were exactly that: uploads/, authorization/, cryptography/ and search/ each held one child and no source, as did authentication/oauth2server/ and authentication/webauthn/. A directory in that shape shows a relationship to a package this repository does not hold — cryptography has never existed here — and a package path is the most breaking thing in Go, so the six were flattened inside the /v14 major rather than bought as a /v15 later. The children carry the relationship in their own names now: mediaregistry, rbac, shredding and searchsync at the root, and authentication/oauth2serverstore and authentication/webauthnsessions into the parent that stayed.

The nested stores are self-contained. What was not self-contained was the configuration: a config subpackage that picked a store by dispatching on a provider string named every package it might build from, so three of them named a table. The rule that predicts it is worth stating once, because it is what any future straddle will be measured against:

A config that takes a store as a parameter is clean; a config that builds one by dispatching on a provider string is stuck. The provider string exists because a second implementation exists, so it belongs with the implementation that created the choice.

So authorization/config, authentication/webauthn/config and authentication/oauth2server/config kept everything that needs no table and went to primitives-go, and the provider string, the store's own config block and the dispatch moved to a config subpackage beside the store, which stayed here: rbac/config, authentication/webauthnsessions/config and authentication/oauth2serverstore/config. The domain half embeds the primitive half's Config with no env tag on the embed, so every environment variable an operator sets resolves at the name it always did, and each package's doc.go records the decision and the two alternatives that were refused.

notifications/mobile was the mirror and needed no split at all: it named notifications only to spell a DI key, for a one-method interface it already owned, so notifications/config registers that narrowing instead and the push providers left with the other primitives.

None of this is enforced by prose. internal/tiercheck is the roster: every package in the tree is named, checked against this table in both directions, and a package ruled a primitive fails there, because a primitive is a package this repository does not hold. It checks the shape of the tree as well as the ruling on it — a directory holding no Go files and exactly one Go-bearing child fails, so a seventh indirecting parent cannot appear quietly and be discovered a tag too late. The direction the split bought — primitives-go imports platform-go from nowhere, ever — is checked on the other side, by primitives-go's internal/tierguard, which needs no roster because the answer is the same for every package in that module.

service is neither tier and is why the split did not split it: it is one walk of one config that registers both modules, and a consumer of both sees the wiring it saw before. errormappers is the small half of the same job — the one call that tells the two transport registries what the domain tier's sentinels mean — kept out of service so that a consumer wiring three packages by hand does not import the config tree to make it.

Transports

A component here that owns data ships a Store interface, a SQL implementation of it, the DDL for whichever dialects the matrix below grants it, and a mock. For most of them it stops there: the HTTP handlers over that store are not missing, they are yours, and a library that shipped them would be versioning your /api/v1/users on its own release cadence, in types your proto does not have, under a scoping rule it guessed.

All three of those were properties of a module that also held the primitives, and the split has answered each. The cadence is the domain tier's own now, since nothing else rides on a release it is in — a fix to retry is a primitives-go release a consumer takes without reading a migration note. The types are shipped: primitives-go ships filtering.proto inside its published module and a domain's .proto travels the same way from here — generated into Go beside it, and into a consumer's Swift, TypeScript and Kotlin from the same file. The scope is not guessed, because tenancy.Scope exists and a domain transport binds it off the caller rather than off a request field.

So the line moves, one domain at a time, and identity is the first across it. The paragraphs that follow are in the order they landed, and each names what its own crossing decided rather than its place in the queue: a domain that crosses next is a paragraph appended, not ten ordinals re-counted. identity/grpc serves the directory: its RPCs, the .proto they are described by, a typed client, and the permissions each one wants. What it still does not ship is the policy — who is calling is an interface the consumer's own authentication interceptor satisfies, and what each method requires is a default map a consumer composes into its own. That is the same bargain identity always stated, one layer further out: a consumer keeps its policy and whatever columns are genuinely its own, and does not keep a users table, the transaction-shaped code around one, or the service and converters over that.

authentication/signin crosses differently: it owns no table itself, and the one it drives is optional. It is sign-in — the order argon2, totp, tokens and identity are used in, which is the code every application writes over those four and the code where their bugs live. The engines each do one thing and store nothing; the directory stores what they produce and never calls them; nothing joined them up. What it decides is the refusals, and it collapses several of them into one sentinel on purpose, because telling an unknown handle from a wrong password is telling an attacker which half of the guess was right. What it refuses to decide is the rest: whether a second factor is mandatory, whether the administrative door exists, whether a sign-in outlives its token, how long each of the two lives and what a token carries are options with defaults. The one that brings a schema is WithRefreshTokenStore: name one and a sign-in hands back a rotating pair, where a spent token presented a second time ends the login rather than sharing it; name none and the service is what it was before, one token per sign-in and no rows anywhere. authentication/signin/grpc serves all of it, and is the one surface in the module that reads its tenant off the connection rather than off a caller — because a caller signing in has not become one yet.

It also turns its own tokens back into callers, which it once left to the consumer. That ruling now covers only tokens this module did not mint. For a token signin minted, resolved through identity's directory, every input was already this module's, and every consumer wrote the same interceptor over them. One consumer's copy got the part that matters most wrong: it gave an administrator's ordinary sign-in their service roles. signingrpc.NewPrincipalExtractor verifies the token and resolves the principal through the read that refuses a banned user. It carries the login's family so ListSignIns can mark the current one, and grants service roles only on a token minted through the administrative door. A seam can say what an ordinary-door token keeps; by default it keeps none. Grants come from the consumer's role policy, and a deployment that also accepts another kind of token chains its own extractor in behind this one. Its interceptors enforce a table of which methods need a caller. The table is built from each surface's own method lists, and a method nobody declared is refused. The application installs it: service builds no extractor, so a composition root names it to service.Transports and puts its interceptor in the gRPC chain and its middleware on the router.

Somebody acting as somebody else. This module once ruled impersonation not a platform notion: every layer had room for one identity, so the only way to fit an operator into a request was to put the subject's ID where the actor's belonged — a working system, and an audit trail that says the subject did it. The objection stands; it is the reason for a second slot rather than for none, because the deployments with an operator tool told that lie anyway, in their own interceptors. The platform owns the mechanism and the deployment owns the policy. callers.Delegated is the optional interface a principal answers with the operator behind it, and callers.ActorOf names who is really acting. signin.Service.IssueImpersonationToken mints a short-lived token with no refresh token behind it, carrying signin.ClaimActor, and refuses every call with ErrImpersonationDisabled until WithImpersonationPolicy names the rule — the platform names no permission for it. signingrpc's extractor turns the claim back into a Delegated caller and refuses it once the operator is banned. audit.Actor.Impersonator records the operator beside the subject the entry is filed under, in a column inside the hash chain, so audit.Query.ImpersonatorID, audit/privacy's export and audit.Erasure.CountMentions all find the operator. Whether an impersonated request carries the operator's grants or the subject's is the consumer's GrantsResolver's call, and no RPC exposes the door: it belongs behind the deployment's own operator surface.

audit crosses too, and it is the one that ships strictly narrower than its own interface. audit/grpc serves the Reader and nothing else: read one entry, page them, verify a scope's hash chain. Record is not there and cannot be — an audit entry that can commit while the change it describes rolls back, or the reverse, is not a record of what happened, which is the sharpest instance of the rule that a write already inside your transaction is not an RPC. ListAcrossScopes and GetAcrossScopes are not there either: they are the operator's reads of every tenant's events, so the scope binds off the connection and the schema reserves the field name, which makes the absence something protoc enforces rather than something a reviewer has to notice. What makes the crossing worth it is Verify — establishing that nobody edited, removed or reordered an entry is the capability a hand-written log reader never gets around to, and the one most worth calling remotely and on a schedule, which is why it is its own grant rather than a second use of the read one.

notifications crosses in both halves, and they cross for two different reasons. The inbox half is the bell icon — list, list unread, get, mark one read, mark them all read, archive — which is the screen every consumer's application has and the code every consumer otherwise writes. The registry half is the strongest RPC case anywhere in the ten, because the caller is literally a remote device: a handset re-registers on every app launch and every token rotation, and the registration converges on (platform, token) rather than inserting, so a handset that changes hands has one owner.

Three of its store methods stay behind, and they are three different shapes of machinery rather than three instances of one — which is why this is the package the distinction is worth reading in. CreateNotification is the transactional companion: it files a notification in the caller's transaction so that it commits with the thing the notification is about, and an RPC would give you a refused order that has already told somebody it shipped. ListDevicesByPrincipals is the internal fan-out, one query for the tokens an announcement has to reach. InvalidateDeviceToken is the provider callback hook, and it is the one absence that is a security property rather than a shape: it removes a token whoever it belongs to, on the word of APNs or FCM, and published as an RPC it would delete any handset's registration in any tenant on the say-so of a caller claiming a provider said so. Nor does any response carry a device token: it travels in on one message, in one direction, so listing devices cannot become the call that harvests every push address an account holds.

comments is across as well, and it is the surface where the opaque-catalog ruling is load-bearing. Threading one level deep, scoping, paging, editing, archiving and erasing is the same code in every application; what varies is the catalog of things that can be commented on, and the package already refuses to guess at it. That refusal is what makes a transport safe to ship: a target type stays an opaque string in the proto rather than becoming a generated enum, so adding a newsletter is your release and not ours, and the optional existence check stays a Go func on your side of the surface. Two other things come off the connection rather than out of a request field, and the schema reserves both names so they cannot come back: the scope, as everywhere here, and the author — a comment is a sentence attributed to somebody, and attribution a client could choose says whatever the client wanted. What that leaves is the second question a grant on the method cannot answer, whose comment this is, and comments/grpc ships it as a seam with a closed default: say nothing and authors edit and archive their own words and nobody else's.

webhooks is the one where the interesting half of the ruling is what stayed behind. Endpoint CRUD, subscription CRUD and the delivery log are on the wire, which is endpoint management: the half of webhooks that is a resource rather than a protocol, and the only half a person ever touches. The rest of its store is the delivery pipeline, and Enqueue is the one worth naming here because it is the only absence that is genuinely consumer-facing. It writes a delivery and one dispatch per endpoint in the caller's transaction, so that both commit with whatever else that transaction did; an RPC moves the write into a transaction of its own, at a moment the caller does not choose, and what you get back is a delivery for a row that rolled back or a committed row nobody was told about. The other thing that does not cross is an endpoint's signing keys: they travel in on exactly one request and there is nowhere in the schema for a response to put them, because a key readable back over an administrative API is a key anyone who can read that API can forge deliveries with.

billing crosses read-biased. billing/grpc serves part of its store: the catalog and its administration, an account's own subscriptions, purchases and ledger, an operator's page over each of those three, and the Archive* set. The absences are the interesting half. Most are writes whose caller is not a client at all — a Stripe or RevenueCat callback, or the checkout handler that created the payment intent, each already inside a transaction that is also writing an audit entry and an outbox event — and the lookups by a payment provider's identifier belong to that same callback path; the last is the existence check a write makes on its way to inserting. What the surface refuses to ship is a reading: there is no GetAccountStanding and no is_active field anywhere in billing.proto, because which reported status leaves an account entitled is your policy. Two packages beside the store are where it lives: billing/plans turns a set of subscriptions into the plan a caller is on, and billing/standing turns a status a processor reported into the standing identity stores. Each ships the strict reading as a value you pass rather than a default you inherit, so taking it is a deployment agreeing with it. billing/sync is the third package beside the store and the only one that writes: it is the order those store methods are called in when a processor delivery arrives — look the agreement up by the provider's identifier, open it or move its status and paid period, acknowledge a redelivery — on the handler's own transaction, taking the other two packages' readings as arguments rather than making either of them itself.

issuereports is the one whose interesting half is a single method. All but one of its store's methods are on the wire — the filing, the reads, the queue listings, the revision, the archive and the move — and the move is why the surface is worth having. TransitionReport is a compare-and-set: it carries the status the caller believed the report held as well as the one it should move to, and the statement requires the row to still hold the first. Without that guard, two triagers resolving the same report both succeed, the second note overwrites the first, and nothing anywhere says so. A wire widens the window between the read a decision was made from and the write that records it from microseconds to a screen and a person, so the conflict is the ordinary case there rather than the rare one, and ErrStatusConflict is a refusal a client acts on: re-read, and decide about the status it is in now.

The one that stays behind is DeleteReportsByReporter, which destroys every report one person filed. It runs inside the caller's transaction so that a subject's reports and the rest of their footprint commit or roll back together, and an RPC is exactly a caller choosing when that commit happens. It is reached through issuereports/privacy, from your own erasure run. The other thing the surface does not carry is a reporter on any write: a report is filed by whoever is calling, and one a client could name is a report filed in somebody else's words.

settings is the one where the interesting half of the ruling is a proto design decision. All but one of its store's methods are on the wire, split into the two audiences the store already splits into: a catalog an operator administers, and the answers a person gives about themselves. Resolve is the point of it — a stored value falling back to the definition's default, so that anybody who has not chosen gets an answer rather than a missing row — and it is the method a hand-written service gets subtly wrong. So a resolved value crosses typed, as a oneof of the four kinds settings.Kind names, rather than as the text the row holds plus the kind to parse it with: putting the parse on the wire is putting the bug on the wire, one generated client at a time. The definition's default and its allowed values stay strings, because those are what a write is checked against byte for byte, and a typed round-trip would rewrite the bytes the check is made with. The method that is not, DeleteValuesForSubject, is erasure and stays behind for the reason every erasure does: it commits inside the transaction that removes the rest of the person.

waitlists is the one where nothing stayed behind. Every one of its store's methods is on the wire, which is unusual on this lane: every carve-out elsewhere is one test applied to different machinery — is the realistic caller a worker on a timer, a processor callback, or your own code inside your own transaction — and a waitlist has no queue protocol, no fan-out and no provider callback. What it has instead is two audiences. Five RPCs are the signup page — the open catalog, the form, the confirmation link, and the two ways off the list — and are reached by somebody who has not signed in and, on a pre-launch list, has nothing to sign in to; the rest are whoever is running the launch. So the tenant comes off the caller where there is one and off the connection where there is not, which is authentication/signin/grpc's arrangement applied to half a surface. And Withdraw is public and names a row, which no grant on a method could ever have been about, so the standing to move that row is a seam a consumer answers. The link in a mail does not need that seam: built with WithConfirmation, the surface runs the double opt-in itself — a join is held pending, a confirmation link and an unsubscribe link are minted through links and handed to the consumer's mailer, and Confirm and Unsubscribe redeem them, with the token as the whole of the standing. The read that stayed administrative is the one worth naming: "is this address on this list" is what the table holds, and answering it to anybody who can reach the port would make the surface an oracle over it.

Ten more were ruled on together, and each is to follow identity. The transport is not uniform and neither is the subset of a store that crosses:

package verdict transport carved out, and why
waitlists wire surface, full gRPC —
comments wire surface, full gRPC the two bulk deletes — erasure machinery
issuereports wire surface, full gRPC DeleteReportsByReporter — erasure machinery
settings wire surface, full gRPC DeleteValuesForSubject — erasure machinery
notifications wire surface, both halves gRPC CreateNotification, ListDevicesByPrincipals, InvalidateDeviceToken
webhooks wire surface, management + history gRPC Enqueue, EndpointsForEvent, and the delivery machinery its store documents
billing wire surface, read-biased gRPC the four status moves, whose caller is a processor callback already inside your transaction
audit wire surface, read-only and scope-bound gRPC Record, GetAcrossScopes and ListAcrossScopes
dataprivacy wire surface over the existing Service HTTP —
mediaregistry binding for the bytes, wire surface for the rows HTTP and gRPC GetObjectByKey — a key is not an address; ListObjects — an operator's read; ArchiveObjectsForOwner — erasure machinery

mediaregistry's row was first ruled a binding alone — the guarded serve, with every store method kept off the wire on the premise that a listing is a resource surface over a consumer's noun. The premise did not hold: nothing in the registry is the consumer's noun. An object is bytes in a bucket and the row saying whose they are, and every rule a product has about them — which types, how large, where in the bucket, what gets metered — is a value rather than a shape. So mediaregistry/grpc ships beside the serve route, with each of those rules an option carrying a default: uploading as a client stream that is never buffered, registering bytes already in the bucket under the caller's own part of it, and reading back what is theirs, with every refusal answered as an absence. An upload may be attached to its sender or to nothing; an attachment to one of your nouns goes through your own RPC, which authorizes the subject and calls mediaregistry.StoreAndRecord.

Seven get nothing, and saying so is the point of this section rather than leaving them unmentioned: metering, saga, timers, workqueue, outbox, retention and entitlements are machinery. Their methods are called by a worker on a timer, or by your own code inside your own transaction, which is the same test the carve-outs above are made by. Owning a store is not what puts a package on the list; having a caller who is somebody else is.

series arrived after that ruling and is on neither list yet. By the same test it is owed a surface — skip, close, end, move and make up are pressed by a person, not a timer — and only its horizon worker is machinery. It ships without one until it has its own ruling, and its first consumer draws its week view from the store in-process.

One of the ten is not the house default, and it has a stated reason. dataprivacy is on HTTP because its flow already is. Progress is answered by operations/http against Request.OperationID and the same event stream every other long-running thing here uses, Confirm is reached by somebody clicking a link in a mail the notifier sent, and the artifact arrives as a freshly minted, expiring DownloadURL. A gRPC surface would put submit, confirm and cancel on one protocol while the confirm click, the progress stream and the download all lived on another.

The flows over those nouns are the other half of the same list, and sign-in was the first of them. Password reset is the second: authentication/passwordreset ships a Service beside its Store, which mails the link after the commit rather than inside it and spends it with the password change and the revocation in one transaction. Passkeys are the third: authentication/passkeys/grpc runs the ceremonies over authentication/passkeys' Service and answers a finished login with sign-in's own token, minted through IssueForPrincipal, so a passkey is another way into the same sign-in rather than a second token stack. Session management and email verification are each their own addition over an engine this module already ships, rather than a branch inside the password flow.

The line the primitives are held to went with them. It read: a module ships a transport for a primitive only where the shape of the request is decided by something other than the consumer's domain — a probe, a protocol, a middleware contract, or a third party's payload — and the probes, the middleware and webhooks/inbound's receiver for a Stripe or GitHub callback are primitives-go's to hold to it. What is left here is the second half of that sentence, and it is the whole list.

Transport Kind Whose shape it is
billing/http binding a payment provider's callback, whose status code the provider acts on
mediaregistry/http binding an object's bytes, guarded by the row rather than by knowledge of the key
sessions/http binding a signed cookie, whose security properties are ours
audit/grpc resource surface reading the audit log and verifying its chain — over audit.Reader
authentication/oauth2clients/grpc resource surface an administered OAuth2 client registry — over oauth2clients.Service and oauth2clients.Store
authentication/passkeys/grpc resource surface enrolling a passkey and signing in with one, into sign-in's token — over passkeys.Service
authentication/passwordreset/grpc resource surface ask for a reset link, check one, spend one — over passwordreset.Service
authentication/signin/grpc resource surface sign-in and the credentials a person changes about themselves — over signin.Service
billing/grpc resource surface the catalog, the agreements, the sales and the ledger, read-biased — over billing.Store
comments/grpc resource surface one noun and its whole lifecycle — over comments.Store
dataprivacy/http resource surface submit, confirm, cancel and read a privacy request, and download an export — over dataprivacy.Service
identity/grpc resource surface the four nouns and their lifecycle — over identity.Service and identity.Store
issuereports/grpc resource surface the report queue and its guarded lifecycle — over issuereports.Store
mediaregistry/grpc resource surface uploading, registering and reading back the caller's objects — over mediaregistry.Store and uploads.UploadManager
notifications/grpc resource surface the in-app inbox and the device registry — over notifications.Inbox and notifications.Registry
operations/http resource surface poll, list, cancel, subscribe — over Operation
settings/grpc resource surface the catalog, the answers stored against it, and what a setting resolves to — over settings.Store
waitlists/grpc resource surface the catalog, the queue and the two audiences that reach them — over waitlists.Store
webhooks/grpc resource surface endpoint management, subscriptions and the delivery log — over webhooks.Dispatcher and webhooks.Store

The bindings are not surfaces. sessions/http binds a store to a cookie, and a cookie's signing, encryption, HttpOnly, Secure and SameSite are security decisions this module already made — there is no resource of yours in it.

mediaregistry/http makes the same claim about an object's bytes. Its store's documentation heads a section "Why the row is the access control" — whether this caller may read this object is answered from the owner and the scope on the row, not from the bucket — and then declines to act on it, because nothing in that package opens, reads or removes an object. The rows cross on mediaregistry/grpc; the bytes cross here, because the guarded serve is the half that gets written wrong: an unguessable key as the only protection a private document has, and a key is not a secret. What crosses instead is one route and the guard in front of it, and the decisions that come with it are security properties rather than API design — the row is read before the bucket is opened, a refusal is indistinguishable from an absence, a content type a browser executes is never served inline, and nothing is cached by a shared proxy. There is no resource of yours in that either: what is on the wire is bytes and a content type.

billing/http binds a payment provider's callback to billing/sync. The payload is the provider's and the verification is capitalism's; what is left is the status code, which the provider acts on and the hand-written endpoints got wrong — a 400 for a database that blinked tells the provider to drop the delivery. That code is decided here, once: 400 only for a delivery that failed verification or could not be parsed, 500 for anything a retry could fix.

The rest are resource surfaces, and they get there by two routes. operations/http is entirely this module's own resource: an Operation, its two-tier progress and its state machine are types you did not define, and polling one or subscribing to its server-sent events is the pattern's protocol rather than your API. Starting an operation is yours, and is deliberately not there. identity/grpc, authentication/signin/grpc, authentication/passwordreset/grpc, authentication/passkeys/grpc, authentication/oauth2clients/grpc, dataprivacy/http, audit/grpc, notifications/grpc, comments/grpc, webhooks/grpc, billing/grpc, issuereports/grpc, mediaregistry/grpc, settings/grpc and waitlists/grpc are the other kind — a domain's own transport, shipped under the rule above rather than as an exception to it, and every resource surface but operations/http has crossed this way. All of those are gRPC but one, for the reason given above: dataprivacy's flow was on HTTP before there was a handler in it.

Who may use a route. Every gRPC surface declares a namespaced permission per method in its permissions.go, and three of the HTTP ones do too, per route: dataprivacy/http, mediaregistry/http and operations/http each export a Permissions map keyed by route (POST /operations/{operationID}/cancel), the Route… constants those keys are spelled with, and OwnStandingRoutes — the routes reached on what the caller is rather than on a grant. There are three of those: following an operation by polling it or subscribing to it, which is how a person watches their own export or erasure, and dataprivacy's confirmation link. The platform declares the permissions and grants none; which a member holds is the consumer's policy, exactly as on gRPC, and withholding a route's permission is how a deployment keeps that route to its operators. Each surface checks its routes with the authorization/http enforcer the consumer builds over the grants its gRPC interceptor reads — WithEnforcer, or service.Transports.HTTPEnforcer for all three — before anything is read, so a caller without the grant is refused as 403 whether or not the identifier they named exists. A surface given no enforcer refuses every route its Permissions names rather than serving it, which is what a fail-closed gRPC enforcer does with a method nobody declared. authorization/http cannot fail closed on a route nobody guarded, so each surface's own tests mount its real handlers and check that every route is in exactly one of the two lists. The fourth HTTP surface, the OAuth 2.1 authorization server service mounts from Config.OAuth2Server, declares none: it authenticates its own callers, a client by its secret at /token and /revoke and a person at /authorize.

The table is not written by hand either. internal/cmd/readmegen emits it on make generate from the http and grpc directories the tree ships, and refuses to emit a row for one whose own doc.go does not name its kind and whose shape it is standing in for. A package that grows handlers therefore cannot reach main without somebody having said which side of the line they fall on.

The client contract

What a client of one of those transports owes is written down too, once, in docs/client-contract.md: the seams a client injects, the sign-in state machine, refresh token rotation, the error channels to branch on, and pagination. It is in no particular language, and platform-client-ts and platform-client-swift implement it rather than each reconstructing it from the proto comments.

It lives here rather than beside either client for the reason the protos do: it describes this module's wire behaviour, so a change to that behaviour updates it in the pull request making the change. Where a rule needs a server newer than some tag, it names the tag — the clients pin one, and a rule describing unreleased main is a rule that breaks a session.

SQL Dialect Support

database speaks Postgres, MySQL and SQLite, and so does every package that stores anything through it. A package that narrowed would be spoken for here — once, before you choose packages, rather than package by package as each constructor refuses at wiring time.

A ✓ means the package ships DDL for that dialect, and — for every package whose statements have been ported onto the generated tier — executes a querier emitted against it. Everything unticked returns dialect.ErrUnsupported at construction, never a partial store or a migration that creates nothing.

MySQL means MySQL 8, and that is a narrowing rather than a clarification. Every container suite here ran against MariaDB from this repository's first commit, with no reason recorded anywhere for why — so the column said mysql, the tests verified MariaDB, and nobody could tell from either which one was promised. They now agree: the suites run mysqltest's own default, which is stock MySQL 8.

The gap was not free while it lasted. MariaDB has no FOR SHARE, which shaped statements elsewhere in this module, and it answers a concurrent UPDATE that filters on the column it assigns with ER_CHECKREAD where MySQL 8 resolves the same situation internally — a failure this module carried without knowing which engine it was for.

A MariaDB deployment is therefore untested rather than unsupported. Nothing refuses it, the DDL and the statements are the constrained subset MariaDB accepts, and it will very likely work. What is gone is anybody checking — so a consumer running MariaDB owns that verification, and the honest expectation is that the next difference between the two engines is found by them rather than here.

Package Postgres MySQL SQLite
audit ✓ ✓ ✓
authentication/grants ✓ ✓ ✓
authentication/oauth2clients ✓ ✓ ✓
authentication/oauth2serverstore ✓ ✓ ✓
authentication/passkeys ✓ ✓ ✓
authentication/passwordreset ✓ ✓ ✓
authentication/phonecodes ✓ ✓ ✓
authentication/signin/magiclinks ✓ ✓ ✓
authentication/signin/recoverycodes ✓ ✓ ✓
authentication/signin/refreshtokens ✓ ✓ ✓
authentication/webauthnsessions ✓ ✓ ✓
billing ✓ ✓ ✓
comments ✓ ✓ ✓
dataprivacy ✓ ✓ ✓
identity ✓ ✓ ✓
issuereports ✓ ✓ ✓
links/database ✓ ✓ ✓
mediaregistry ✓ ✓ ✓
metering ✓ ✓ ✓
notifications ✓ ✓ ✓
operations ✓ ✓ ✓
outbox ✓ ✓ ✓
rbac ✓ ✓ ✓
saga ✓ ✓ ✓
series ✓ ✓ ✓
sessions/database ✓ ✓ ✓
settings ✓ ✓ ✓
shredding ✓ ✓ ✓
timers ✓ ✓ ✓
waitlists ✓ ✓ ✓
webhooks ✓ ✓ ✓
workqueue ✓ ✓ ✓

Why nothing narrows

One reason, and it is a claim rather than a translation. On Postgres the claim is a single statement that selects due rows, locks them with SKIP LOCKED, increments attempts, extends the lease and hands the keys back with RETURNING. MySQL has SKIP LOCKED but no RETURNING, and SQLite has neither and no row locks at all, so on those two the same claim is several statements held in one transaction: a read, an update and a read-back by the claim's name.

That is a cost rather than a reason to narrow, and workqueue and timers pay it: each keeps its Postgres claim as the one statement, and on MySQL and SQLite splits it, fenced by the name the claim mints so that a write can land only under the claim that took the row. timers locks its candidates by primary key rather than by the range that found them, because on MySQL a locking range read also locks the first row past it — the next set's earliest timer, as often as not. webhooks and outbox already claimed that way on all three, and operations followed workqueue: its guarded writes hand their row back through RETURNING on Postgres and read it back on the same transaction on the other two. No package narrows today. One that did would state where it stands in its own doc.go, and this list is that statement:

Widening one is the port workqueue, timers and operations took, not a new design: one package, a statement set per shape its dialects need, and a switch on the client's dialect at construction — never a provider subpackage per database.

Narrowings that are not rows

Some dialect-dependence is a capability inside a package that serves all three, and a row would misreport it either way:

  • outbox stores and relays on all three. Its LISTEN/NOTIFY wakeup is Postgres-only and reported as outbox.ErrNotifyUnsupported if configured elsewhere; without it a relay polls, which is later rather than wrong. Its SKIP LOCKED claim mode degrades to a lease on SQLite — which claims just as exclusively, by guarding the write on the lease it read, and only contends where the lock would have skipped.
  • workqueue queues on all three. Its LISTEN/NOTIFY wakeup is Postgres-only and reported as workqueue.ErrNotifyUnsupported if configured elsewhere; without it a worker polls, which is later rather than wrong.
  • operations runs on all three. Its LISTEN/NOTIFY push to watchers is Postgres-only and reported as operations.ErrNotifyUnsupported if configured elsewhere; without it a watcher polls, which is later rather than wrong.
  • timers schedules on all three. Its LISTEN/NOTIFY wakeup is Postgres-only and reported as timers.ErrNotifyUnsupported if configured elsewhere; without it a poller sleeps to the next instant it knows about or its poll, which is later rather than wrong. SQLite keeps instants to the millisecond, and a scheduled instant is rounded up to one, never down.
  • retention sweeps all three, and ships no DDL: the table, the timestamp column and the batch key arrive from a Policy written at run time, so there is no schema of this module's to render for a dialect.

A primitive that names a dialect is the same case and is primitives-go's to report: distributedlock/postgres and search/vector/pgvector are named providers beside memory, redis and qdrant, chosen by config the way cache/redis is, and picking one is picking Postgres, which is what its name says.

The matrix above is not written by hand. internal/cmd/readmegen emits it on make generate from the DDL each package ships, checks that against the dialects its generated querier was emitted for, and refuses to emit a short row for a package whose doc.go does not say why it is short. A package that gains or loses a dialect therefore changes this file on the next generate, and the generated-files workflow reds until that change is committed.

Development

make setup          # Install dev tools and download deps
make format         # Format all Go code (imports, field/tag alignment, gofmt)
make lint           # Run golangci-lint (Docker) + shellcheck
make test           # Run tests (race detector, shuffle, failfast)
make build          # Build all packages
make generate       # Regenerate moq mocks after changing a mocked interface
make bench          # Run benchmarks

Formatting runs locally with gci, goimports, betteralign, tagalign, and gofmt. Linting runs in Docker against the golangci/golangci-lint image (42+ linters, golangci-lint v2 format).

Testing conventions

  • stretchr/testify is banned (assert, require, and mock), enforced by depguard. Use shoenig/test for assertions (test for non-fatal, must for fatal) and matryer/moq for mocks.
  • Tests run in parallel by default and use subtests throughout.
  • Container-backed tests use testcontainers-go, live in-package (typically containers_test.go), and gate on RUN_CONTAINER_TESTS=true.
  • make test runs CGO_ENABLED=1 go test -shuffle=on -race -vet=all -failfast ./... across every package. .scripts/test.sh false runs the suite without container tests.

Contributing

Because main is a development channel and only tagged releases are supported, changes land on main freely and are stabilized before release. Follow the existing package layout (the store, its config subpackage, its migrations, its rendered query corpus and its mock), match the surrounding code, and keep make format lint test green. A package that turns out to be a primitive belongs in primitives-go instead — Primitives and Domains is the rule, and internal/tiercheck fails a roster entry that claims one.

About

a platform to build applications with

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages