Broker API
The Broker API is the external-facing surface for licensed brokers and agents. It lets a broker run a quote through to issuance, see the policies they sold, create client accounts and parties, and track the commission they earned, all scoped to the authenticated broker. Owned by the broker-api service. Most of its work is a thin proxy over Enrollment and Policy Admin; the data it actually owns is commissions and per-broker delegated-authority configuration.
Field reference: column-level detail lives in the catalog under the
broker_apischema (broker_configs,commissions). This page is the narrative.
Authentication and scoping
Every request (apart from /healthz and /readyz) goes through two middlewares: JWT validation against Keycloak, then a broker-role check. The role check requires a broker or broker-admin role (read from a flat roles claim or from Keycloak's nested resource_access.<client>.roles) and a non-empty brokerId claim. The brokerId is injected into the request context and is the scoping key for every operation: a broker only ever sees their own commissions, their own authority config, and policies whose broker_locator matches their brokerId. There is no per-route admin gate today; broker and broker-admin are treated identically by every handler.
What it owns
The service owns two tables in the broker_api schema.
| Table | Purpose |
|---|---|
broker_configs | One row per broker (broker_id is UNIQUE). Holds delegated_authority_limit NUMERIC(12,2) and default_commission_rate NUMERIC(5,4), plus locator. default_commission_rate is the contracted rate applied when a commission is auto-recorded on issuance, or when a manual commission omits a rate. |
commissions | One commission per (broker_id, policy_locator). Holds rate NUMERIC(5,4), amount NUMERIC(12,2), and status. |
Everything else (quotes, policies, accounts, parties) lives in the upstream services; the Broker API holds none of it.
Commissions
Commission amount is always derived server-side, never supplied by the caller. The amount is round(premium × rate) to whole pence, where premium is resolved by looking up the policy in Enrollment and summing its PREMIUM charges in Billing. The request body for POST /commissions carries only policyLocator and an optional rate; there is deliberately no amount field. When rate is omitted (or zero) the broker's contracted default_commission_rate is used. The rate must be in (0, 1], otherwise the request is rejected with 400.
The primary path is automatic. The service consumes Enrollment's policy.issued events and records the commission for the issuing broker without any manual call (see Events). POST /commissions exists as a manual override for cases the consumer did not cover; it runs the same server-side derivation. The consumer path is idempotent per (broker_id, policy_locator) (it looks up an existing commission first). The manual POST is not: it always inserts, and there is no DB unique constraint on (broker_id, policy_locator), so a repeat call creates a duplicate.
status is constrained by a DB CHECK to exactly two values: PENDING (default on create) and PAID. PATCH /commissions/{locator}/pay is the only transition; calling it on an already-PAID commission returns 422. These statuses are local TEXT enums in the broker_api schema, not shared packages/go/domain enums.
Delegated authority
broker_configs.delegated_authority_limit caps the premium a broker is authorised to bind. GET /authority returns the broker's config; PUT /authority upserts both the limit and the default commission rate. The check is premium <= limit, and a broker with no config row is denied by default.
Enforcement is currently detect-only, not blocking. The authority check runs inside the policy.issued consumer, after the policy is already bound. A breach (premium over the limit, or no config) is logged as a warning for follow-up; it does not reverse the policy or stop the commission being recorded. Bind-time enforcement would need a quote-stage premium and is not wired.
API Routes
All routes below require a valid JWT carrying a broker or broker-admin role and a brokerId claim. /healthz and /readyz are unauthenticated.
| Method | Path | Description |
|---|---|---|
POST | /quotes | Create a quote for a client (proxies to Enrollment, stamping the broker as brokerLocator) |
GET | /quotes/{locator} | Get a quote |
PATCH | /quotes/{locator}/price | Price a quote |
PATCH | /quotes/{locator}/underwrite | Underwrite a quote |
PATCH | /quotes/{locator}/issue | Issue a quote into a policy |
GET | /portfolio/policies | List policies in the broker's book (paginated; Enrollment filters by brokerId) |
GET | /portfolio/policies/{locator} | Get one policy, returning 403 if its broker_locator is not the caller's brokerId |
POST | /clients/accounts | Create a client account (proxies to Policy Admin) |
POST | /clients/parties | Create a client party (proxies to Policy Admin) |
GET | /commissions | List the broker's commissions (paginated) |
POST | /commissions | Manually record a commission (amount derived server-side, never sent by the caller) |
GET | /commissions/{locator} | Get a commission (403 if it belongs to another broker) |
PATCH | /commissions/{locator}/pay | Mark a PENDING commission PAID |
GET | /authority | Get the broker's delegated-authority config |
PUT | /authority | Upsert the broker's authority limit and default commission rate |
Events
Broker events use the canonical envelope: eventId, eventType, occurredAt, client lineage (sessionId / activityId / activityName, lifted from W3C baggage), payload, and a state key that freezes the full subject entity as it existed at emit time ({"commission": ...}, {"brokerConfig": ...}, {"policy": ...}). See the Kafka Event Catalog for the envelope and the Event Registry for per-type contracts; the machine-readable contracts and golden examples live in packages/go/domain/eventregistry/registry/<eventType>/.
Consumes
The service consumes the enrollment topic (enrollment-events by default, KAFKA_ENROLLMENT_TOPIC) with group ID broker-api. It filters envelopes for eventType == "policy.issued" and ignores the rest. The producer side is Enrollment (EventPolicyIssued = "policy.issued").
On each policy.issued the consumer fetches the policy from Enrollment. Policies with no broker_locator are skipped (only broker-sold policies earn commission). It then resolves the premium from Billing; because Billing generates the premium charges from the same event, the consumer polls (default 5 attempts, 2s apart) before giving up. If the premium is still unavailable the offset is not committed and the event is reprocessed later. A broker with no contracted rate is logged and skipped (offset committed) rather than retried forever.
Publishes
The service publishes five event types, all to the broker.events topic (pre-created; Kafka auto-create is off), keyed on brokerId. Publishing is direct to Kafka from the emit site, not via an outbox: there is no correlationId stamping, and a publish error is ignored (the state change still commits), so delivery is best-effort.
| eventType | Emitted when | state subjects |
|---|---|---|
broker.appointed | PUT /authority creates the first broker_configs row for a broker | brokerConfig |
broker.authority.updated | PUT /authority amends an existing config row | brokerConfig |
broker.authority.breached | the policy.issued projector finds the issued premium above the broker's limit, or no config row exists (detect-only, after bind) | policy |
commission.earned | a commission is recorded at PENDING: the policy.issued projector (idempotent) or the manual POST /commissions (not idempotent, can emit duplicates) | commission |
commission.paid | PATCH /commissions/{locator}/pay flips a commission to PAID | commission |
broker.authority.breached and the projector-path commission.earned are consumer-driven, so they carry no client session lineage; the interactive paths (PUT /authority, POST /commissions, PATCH .../pay) carry the broker portal's session baggage when present. The only consumer of broker.events today is the debugger, which validates every event against its registry contract.
Dependencies
| Service | How used |
|---|---|
| Enrollment | Quote create/price/underwrite/issue, portfolio policy listing, and policy lookup (broker_locator, policy ID for the premium join). Source of the policy.issued events consumed. |
| Billing | Premium lookup: SumPremiumByPolicyID sums a policy's PREMIUM charges. This premium feeds every commission amount. |
| Policy Admin | Client account and party creation (proxied through). |
| Kafka | Subscribes to the enrollment topic to auto-record commissions on issuance; publishes broker events to broker.events. |
Eligibility is wired but not exposed
An EligibilityClient is constructed and placed in the handler dependencies, but no route uses it. No broker can invoke an eligibility check through this service today. Treat client-coverage lookups as planned, not implemented.
Invariants
- Every request is scoped to the JWT
brokerId. Brokers cannot read or modify another broker's commissions or authority config; cross-broker access returns403. - Commission
amountisround(premium × rate)and is always derived server-side. The caller never supplies an amount; supplying a rate is optional and falls back to the broker's contracteddefault_commission_rate. ratemust be in(0, 1]; an invalid rate is rejected with400.- Commission recording is idempotent on the consumer path (per
(broker_id, policy_locator)); the manualPOSTis not (it always inserts, no DB unique constraint), so it can create duplicates. commissions.statusisPENDING → PAIDonly, enforced by a DBCHECK.payon an already-paid commission returns422.
Caveats
- Authority is detect-only. The delegated-authority limit is checked after the policy is bound and only logs a breach; it does not block binding or reverse anything. Bind-time enforcement is not implemented.
- No admin boundary.
PUT /authorityand every other route acceptbrokerandbroker-adminequally. A plainbrokercan set their own authority limit and default commission rate. - Local enums, not shared.
commissions.statusis abroker_api-localTEXTenum. The broker concept reachespackages/go/domainonly asPolicy.BrokerLocator(a nullable*stringfield), not as any shared status enum. - Premium timing. Because Billing produces premium charges asynchronously from the same
policy.issuedevent, auto-commissioning depends on the consumer's poll-and-retry. A commission appears once the premium charges exist.
