Architecture
The API follows a strict layered architecture. Requests flow API → Service →
Persistence, and domain types (internal/model) carry data across those
boundaries.
Layered architecture
Section titled “Layered architecture”graph TD
subgraph API ["API Layer — internal/api"]
MW[Middleware<br/>logger · otel · JWT/keyauth · CORS]
RT[Echo routes + handlers]
end
subgraph SVC ["Service Layer — internal/service"]
AD[Auth decorator<br/>authz.Check / ScopeOrgs]
CR[Core impl<br/>+ inline audit]
end
subgraph PS ["Persistence — internal/db + storage"]
PG[(Postgres<br/>via sqlc/pgx)]
SB[S3 bucket]
end
RT --> AD --> CR --> PG
CR --> SB
API layer (internal/api/)
Section titled “API layer (internal/api/)”The entry point for all requests, built on Echo v5. api.go holds the route
table; middleware.go holds the chain. Handlers parse and validate input, then
delegate to a service — they contain minimal business logic.
Routes are split into three groups by auth requirement:
- public — CORS only.
- protected — JWT cookie or static bearer key-auth (service-to-service).
- password-reset — a dedicated JWT purpose.
Handlers follow one consistent shape: a function-scoped request struct with
param:"…" / json:"…" tags, c.Bind(req), a service call, then c.JSON(...).
Swagger annotations sit directly above each handler.
Service layer (internal/service/)
Section titled “Service layer (internal/service/)”The core business logic — orchestrating transactions, Temporal, and storage.
It’s organized per domain (auth, bond, premium, document, project,
audit), and within each domain the files split by concern:
service.go— theServiceinterface + thecorestruct + constructors.core_*.go— the real implementations (methods on*core).cache_*.go— the cache decorator, memoizing reads and invalidating them on writes (only active whencfg.EnableCache).auth_*.go— the authorization decorator (methods on*authDecorator), which callsinternal/authzbefore delegating to the core.
Redis is wrapped behind an internal/cache package, initialized only when
cfg.EnableCache is set. Service-level caching is live: each domain service
is wrapped by a WithCache decorator that memoizes Get/List reads and
invalidates them via a scope-based version scheme. Every cache key folds in
a version counter for each scope it touches — a scope being any resource
id.ULID (or []id.ULID) passed to Key(...). Cache.Invalidate(ctx, prefix, ids…) bumps the version counter for each affected ID in a single Redis pipeline,
which atomically expires both that object’s Get entry and every List
entry that referenced it — without scanning keys. Cache.Nuke still FlushDBs
the namespace (internal/cache/cache.go, key.go).
Persistence layer (internal/db/)
Section titled “Persistence layer (internal/db/)”All database access goes through sqlc-generated, type-safe Go. Services never
touch raw rows — they call generated Queries methods and convert results to
model types via FromDB converters.
The decorator pattern
Section titled “The decorator pattern”Cross-cutting concerns are applied with the decorator pattern. Each domain
service is built as a core, then wrapped by up to two decorators — a cache
decorator (WithCache, when cfg.EnableCache) and an authorization
decorator (WithAuth, when cfg.EnableAuthorization). Because WithCache is
applied before WithAuth, a call flows authz → cache → core. Composition
happens in internal/service/service.go:
authenticator := authn.New(cfg)svc.Auth = authservice.New(cfg, db, str, wf, auditLogger, authenticator)svc.Bond = bondservice.New(cfg, db, str, wf, auditLogger)svc.Premium = premiumservice.New(cfg, db, str, auditLogger) // note: no wfsvc.Project = projectservice.New(cfg, db, str, auditLogger) // note: no wf// …
if cfg.EnableCache { cache, _ := cache.New(cfg) svc.Project = projectservice.WithCache(svc.Project, cache) svc.Premium = premiumservice.WithCache(svc.Premium, cache) svc.Bond = bondservice.WithCache(svc.Bond, cache) svc.Auth = authservice.WithCache(svc.Auth, cache) // …}
if cfg.EnableAuthorization { authorizer := authz.New(db) svc.Auth = authservice.WithAuth(svc.Auth, authorizer) svc.Bond = bondservice.WithAuth(svc.Bond, authorizer) svc.Audit = auditservice.WithAuth(svc.Audit, authorizer) // …}Auditing is not a decorator. It’s done inside each core method by calling
an injected *audit.Logger within the same database transaction as the
mutation, so a write and its audit record commit or roll back together. The
logger no-ops for key-auth (service-to-service) requests.
sqlc and the SQL layer
Section titled “sqlc and the SQL layer”There is a single db/ tree. For each table, sqlc.yaml points a generated
package under internal/db/ at that table’s db/NNN_<schema>_<table>.query.sql
(the queries:) with a shared schema: "db/*.migration.sql" — the goose
migrations are the schema sqlc reads, so there is no second tree to keep in
sync. Notable global config:
- Type overrides:
uuid → internal/id.ULID,timestamptz → time.Time,json → json.RawMessage, andencrypted → internal/crypt.Encrypted. - PII columns use the
encryptedPostgres domain (overBYTEA), mapped tointernal/crypt.Encryptedby a singledb_typeoverride. Values transparently AES-GCM-encrypt on write and decrypt on read (user name/email, org name/domain, surety details, invite email, contractor details). See Database Encryption for the full mechanism.
Regenerate after editing any db/*.query.sql with mise run sql (which first
formats the query files with sql-formatter, then runs sqlc generate).
QUERY-method list endpoints
Section titled “QUERY-method list endpoints”Every list endpoint uses the HTTP QUERY method with a JSON body carrying
array filters (*In fields) — not GET with query strings. This lets a list
take rich, repeatable filters (e.g. statusIn, orgIDIn) in a body without
the length and encoding limits of a query string.
There are 17 such endpoints. Each is registered twice in api.go:
pro.QUERY("/bond_request", bond.ListBondRequest)pro.POST("/bond_request/query", bond.ListBondRequest) // twin for tooling that lacks QUERYThe POST /…/query twin exists purely because the toolchain can’t otherwise
express QUERY: swag’s @Router method whitelist stops at PATCH, and OpenAPI
3.1 has no QUERY. So handlers are annotated as POST /x/query placeholders, and
scripts/openapi_query.jq rewrites each one into a first-class QUERY /x
operation while bumping the spec to OpenAPI 3.2 (see
Development › SDK generation).
Two domains arrived alongside the rename. A derived /bond resource
(bond.bond table) holds the finalized bid/final bond records that request
processing produces — QUERY /bond, GET/PUT/DELETE /bond/:bondID (no public
create). Its list filter gained a parentOrgIDIn field that scopes bonds to
child orgs of the given parents (a SQL subquery over auth.organization.parent_id),
alongside orgIDIn / projectIDIn / bondTypeIn. A new premium schema
(premium.rate, premium.rate_tier) adds full CRUD under /rate and
/rate_tier via internal/service/premium/ and internal/api/premium/.
Enums and the bond status sentinel
Section titled “Enums and the bond status sentinel”Enum types live in internal/model as type X string with a typed constant set
and a // @name X annotation for the SDK. Database columns stay plain
string/TEXT — conversion happens at the boundary (request binding writes
string(x), FromDB converters cast back). Enums: OrgType, BondType,
BondRequestStatus, BondFormFieldType, and WorkflowStatus. (The JWT
Purpose type is not an SDK enum — it lives in internal/authn, see
Authentication.)
BondRequestStatus is special: there is no status column. A bond request’s
status is derived from two zero-timestamp sentinels:
sent_at TIMESTAMPTZ NOT NULL DEFAULT '0001-01-01 00:00:00+00',completed_at TIMESTAMPTZ NOT NULL DEFAULT '0001-01-01 00:00:00+00',draft if both are the sentinel, sent once sent_at is set, completed once
completed_at is set — derived identically in the Go FromDB converter and in
the SQL list filter (a CASE comparing against '0001-01-01…', not
IS NULL). The draft → sent transition also fires the
SendBondRequest notification workflow.
Authentication
Section titled “Authentication”Authentication now lives in its own package, internal/authn, which “owns
who is calling”: it mints the session JWT and carries the authenticated
principal through the request context (authorization — what they may do —
lives in internal/authz, see the decorator pattern).
- JWT, HS256 (
golang-jwt/jwt/v5), carried in anHttpOnlycookie namedjwt. Theauthn.Authenticator(authn.New(cfg)) mints tokens; the Echo JWT middleware verifies them. - Claims embed
model.SessionClaims{ UserID, OrgID, OrgType }plus aPurposeand the standard registered claims.OrgType(the role) lives in the token so the frontend can route without a round-trip. The per-userAdminflag lives on the DB user row. - Purpose is a typed enum —
authn.Purpose, eitherPurposeSession("session", 72h TTL) orPurposePasswordReset("password_reset", 10m TTL). It is not amodel/ SDK enum.
- Two strategies on the protected group (
authMiddleware): the cookie JWT (purpose=session) for users, and a static bearer key compared withcrypto/subtle.ConstantTimeComparefor service-to-service calls. The password-reset group uses a dedicatedpwResetAuthMiddleware. - Verification is
jwtMiddleware(secret, purpose)(internal/api/middleware.go) wrappingechojwtwithSigningKey: authn.SigningKey(secret, purpose). ItsSuccessHandlerrejects a token whosePurposedoesn’t match the group (model.ErrUnauthorized) and stamps the principal viaauthn.WithSession.
Errors carry their own status
Section titled “Errors carry their own status”There is no error-mapping middleware anymore. model.Error carries an HTTP
status itself and implements Echo’s HTTPStatusCoder, so Echo’s
DefaultHTTPErrorHandler alone renders the response — a model.Error returns
its own status and a {"message": …} body (the curated message only, never the
wrapped cause), while any plain error surfaces as 500 Internal Server Error.
The sentinels (internal/model/error.go) each pin a status: ErrBadRequest
(400), ErrUnauthorized (401), ErrForbidden (403), ErrNotFound (404),
ErrConflict (409). Wrap(err) attaches a server-side cause for logging,
WithMsg(msg) swaps the client-facing message, and errors.Is matches through
the derivation chain (pgx.ErrNoRows → ErrNotFound).
Temporal orchestration
Section titled “Temporal orchestration”The API is a Temporal client only — it defines no workers or workflows. It dials the Temporal cluster (namespace = deployment environment) with an OTel tracing interceptor and a custom identity propagator (see Distributed tracing), and triggers workflows on two task queues:
- RAG queue:
GenerateField,ParseDocument,DeleteDocument,FindBond,FillBond,ProcessBondRequest. - Notification queue:
SendInvite,SendOTP,SendBondRequest.
internal/workflow wraps the client with Execute(...) and Watch(...).
Two behaviors worth knowing:
- Deterministic workflow IDs —
{taskQueue}/{workflow}/sha256(args). Re-triggering with identical args terminates and replaces the running execution. The RAG service replicates this exact ID scheme so the app’s status subscriptions keep working across the parent/child workflow split (see RAG › Architecture). - Status is streamed, not polled by callers. The old per-caller
GetStatusendpoints are gone; the API exposes SSE watch routes (GET …/watch) that push a bareWorkflowStatus("running"/"completed"/"failed") whenever it changes — see below.
Watching workflow status (SSE)
Section titled “Watching workflow status (SSE)”workflow.Client embeds a Watcher that owns a single background poll loop
(internal/workflow/watcher.go). Client.Watch(ctx, workflow, args…) resolves
the deterministic workflow ID and subscribes to it, returning a
<-chan model.WorkflowStatus. The watcher polls Temporal (describeStatus,
mapping execution state → WorkflowStatus; a workflow Temporal no longer knows
reads as completed) at a 1s tick, ≤16 concurrent, but only for workflow
IDs that currently have at least one subscriber, and fans out changes to
subscribers. A subscriber that falls subscriberBuffer (16) behind is dropped
so its client reconnects and gets a fresh snapshot.
Handlers turn that channel into an SSE stream with the generic
shared.StreamSSE[T] helper (Content-Type: text/event-stream, 15s : ping
heartbeats, each value written as data: <json>\n\n — so a frame is just
data: "running"). The watch routes, all GET on the protected group:
pro.GET("/document/:documentID/parse/watch", document.WatchParseDocument)pro.GET("/bond_request/:requestID/find_bond/watch", bond.WatchFindBondForm)pro.GET("/bond_request/:requestID/process/watch", bond.WatchProcessBondRequest)pro.GET("/bond_request_field/:fieldID/generate/watch", bond.WatchGenerateBondRequestField)pro.GET("/bond_form/:formID/fill/watch", bond.WatchFillBondForm)Each connection watches exactly one workflow. These routes carry no swaggo
response body and are x-speakeasy-ignore’d out of the generated SDKs (the app
addresses them by path — see
App › Workflow status over SSE).
View workflow executions in the Temporal web UI (Google SSO — see
Temporal › Inspecting workflows).
The old mise run temporal helper has been removed.