Skip to content

Architecture

The API follows a strict layered architecture. Requests flow API → Service → Persistence, and domain types (internal/model) carry data across those boundaries.

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

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.

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 — the Service interface + the core struct + constructors.
  • core_*.go — the real implementations (methods on *core).
  • cache_*.go — the cache decorator, memoizing reads and invalidating them on writes (only active when cfg.EnableCache).
  • auth_*.go — the authorization decorator (methods on *authDecorator), which calls internal/authz before 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).

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.

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 wf
svc.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.

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, and encrypted → internal/crypt.Encrypted.
  • PII columns use the encrypted Postgres domain (over BYTEA), mapped to internal/crypt.Encrypted by a single db_type override. 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).

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 QUERY

The 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/.

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 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 an HttpOnly cookie named jwt. The authn.Authenticator (authn.New(cfg)) mints tokens; the Echo JWT middleware verifies them.
  • Claims embed model.SessionClaims{ UserID, OrgID, OrgType } plus a Purpose and the standard registered claims. OrgType (the role) lives in the token so the frontend can route without a round-trip. The per-user Admin flag lives on the DB user row.
  • Purpose is a typed enumauthn.Purpose, either PurposeSession ("session", 72h TTL) or PurposePasswordReset ("password_reset", 10m TTL). It is not a model / SDK enum.
  • Two strategies on the protected group (authMiddleware): the cookie JWT (purpose=session) for users, and a static bearer key compared with crypto/subtle.ConstantTimeCompare for service-to-service calls. The password-reset group uses a dedicated pwResetAuthMiddleware.
  • Verification is jwtMiddleware(secret, purpose) (internal/api/middleware.go) wrapping echojwt with SigningKey: authn.SigningKey(secret, purpose). Its SuccessHandler rejects a token whose Purpose doesn’t match the group (model.ErrUnauthorized) and stamps the principal via authn.WithSession.

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.ErrNoRowsErrNotFound).

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 GetStatus endpoints are gone; the API exposes SSE watch routes (GET …/watch) that push a bare WorkflowStatus ("running" / "completed" / "failed") whenever it changes — see below.

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.