Boundary
Status: feature branch, not merged or deployed
All boundary code lives on two un-merged worktree branches (feat-boundary-service and feat-rating-engine). Mainline /root/olly has no packages/go/geo, no boundary domain types, and policy-admin migrations stop at 0013 (the PostGIS and boundary migrations 0014-0021 are branch-only). Nothing here is running on dev-2 yet. Treat this page as describing built-but-unmerged code, not a live capability.
Boundary is a spatial capability layered onto the Policy Admin service, not a standalone microservice. It stores hierarchical polygons (administrative regions, rating zones, service areas, exclusion zones) and answers point-in-polygon questions: which boundary contains this point, is this point serviceable, which boundary in a named group covers it. Tables live in the policy_admin schema so they can join against rating-factor and product-config rows. The shared packages/go/geo package exposes a typed BoundaryStore interface for in-process callers; the rating engine resolves boundaries through a BoundaryResolver seam whose cross-service HTTP fetch is not yet wired.
Field reference: column-level types and nullability live in the catalog. This page is the narrative; the schema tables below give the load-bearing shape.
What it owns
All tables are in the policy_admin schema.
| Table | Purpose |
|---|---|
boundary_levels | Per-market hierarchy definitions (country, region, county, ...) |
boundaries | The polygons themselves, one MULTIPOLYGON per row, typed by boundary_type |
boundary_groups | Named collections of boundaries (e.g. a rating-zone set for a market) |
boundary_group_members | m:n join between groups and boundaries |
rate_tables is not a Boundary table. It belongs to the rating engine (feat-rating-engine, migration 0020) and has no FK to boundaries. The boundary-to-rate link is runtime-only, resolved through source_path (see below), never a foreign key.
There is no provider_service_areas table in either branch.
The boundary hierarchy
A Boundary carries a MarketCode, an optional integer Level (nil for arbitrary/custom shapes), an optional ParentID pointing at another boundary in the same market, a Code, a Name, and a BoundaryType. Geometry is a single PostGIS GEOMETRY(MULTIPOLYGON, 4326) column. It is not mapped into the Go struct: writes go through raw SQL (ST_GeomFromGeoJSON wrapped in ST_Multi), reads come back as GeoJSON via ST_AsGeoJSON.
boundary_type is constrained by a DB CHECK to: ADMINISTRATIVE, SERVICE_AREA, RATING_ZONE, SCHEME_REGION, EXCLUSION_ZONE. Serviceability is derived from two of these: a point is serviceable if it falls inside a SERVICE_AREA and outside every EXCLUSION_ZONE in the same market.
Consumers
| Consumer | Use | Status |
|---|---|---|
| Rating engine | Geographic rating factors: $boundary:<level> and $zone_group:<code> resolution | Built (on feat-rating-engine) |
| Any in-process caller | Containment, group lookup, serviceability via BoundaryStore | Built |
| Care: nearest-provider lookups | - | Planned, not implemented. No nearest-neighbour query exists. |
| Provider: service-area declaration and nearest-by-specialty search | - | Planned, not implemented. No provider service-area table or endpoint exists. |
There is no nearest-neighbour capability anywhere in the code. The Point and NearestResult structs in packages/go/geo/types.go are dead code, referenced by nothing; no ST_Distance/ST_DWithin query exists.
Source-path grammar
The rating engine references boundary data (and ordinary document fields) through a source_path string on a RatingFactor. Resolution lives in packages/go/rating/resolve.go (resolveSourcePath), and runs at rate/query time, not config-load time. There is no separate sourcepath package and no up-front validation pass: a malformed path surfaces as a rating error when the factor is evaluated. Parsing uses prefix matching plus fmt.Sscanf.
Two boundary prefixes are recognised:
| source_path | Semantics |
|---|---|
$boundary:<int level> | Resolve the boundary at that integer level containing the point. Market comes from the element/policy document, not the path. |
$zone_group:<code> | Resolve which boundary in group <code> contains the point. |
For both, lat/lng (and, for $boundary:, the market code) are pulled from the element data or policy document via extractLocationFromDocs; market defaults to GB when absent.
All other paths are dotted namespace.sub.key lookups into the in-memory documents, with at least three segments required:
| Namespace | Source map |
|---|---|
element.data.* | element data |
element.coverage_terms.* | coverage terms |
policy.document.* | policy document |
Unknown namespaces or boundary levels return an error from resolveSourcePath at evaluation time.
The Go interface
Package packages/go/geo ships interfaces only; the PostGIS implementation is GORMBoundaryRepository in policy-admin.
type BoundaryStore interface {
FindAtLevel(ctx context.Context, marketCode string, level int, lat, lng float64) (*domain.Boundary, error)
FindInGroup(ctx context.Context, groupCode string, lat, lng float64) (*domain.Boundary, error)
FindAll(ctx context.Context, marketCode string, lat, lng float64) ([]domain.Boundary, error)
ContainedBy(ctx context.Context, boundaryID uuid.UUID, lat, lng float64) (bool, error)
Ancestors(ctx context.Context, boundaryID uuid.UUID) ([]domain.Boundary, error)
Children(ctx context.Context, boundaryID uuid.UUID) ([]domain.Boundary, error)
IsServiceable(ctx context.Context, marketCode string, lat, lng float64) (bool, error)
}domain.Boundary fields: ID uuid.UUID, Locator string, MarketCode string, Level *int, ParentID *uuid.UUID, Code string, Name string, BoundaryType string, Properties []byte (jsonb), EffectiveFrom time.Time, EffectiveTo *time.Time, CreatedAt time.Time. There is no GroupCode field and no WKT/polygon field on the struct (geometry stays in the DB).
API routes
All routes are served by policy-admin. Two mounting groups exist (see internal/handler/handler.go):
- External routes are wrapped by
auth.JWTMiddlewareonly. A valid Keycloak JWT is required; there is no role check. Any authenticated caller can create, import, or delete boundaries. Aboundary-adminrole gate is not implemented. - Internal routes under
/internalhave no middleware at all: no JWT, no token header. There is noX-Internal-Tokencheck and noINTERNAL_API_TOKENenv var. These endpoints rely entirely on network-level isolation (not reachable outside the cluster/VPC), which is a documented gap, not an enforced control.
Request and response bodies are camelCase JSON.
Boundary levels
| Method | Path | Body | Response |
|---|---|---|---|
POST | /boundary-levels | {marketCode, level, name, parentLevel?} | 201 the level |
GET | /boundary-levels/{marketCode} | - | 200 {levels: [...]} |
Boundaries
| Method | Path | Body / params | Response |
|---|---|---|---|
POST | /boundaries | {marketCode, level?, parentLocator?, code, name, boundaryType, geoJSON, properties?, effectiveFrom?} | 201 the boundary |
POST | /boundaries/import | {marketCode, level?, boundaryType, featureCollection} | 201 {imported: <count>} |
GET | /boundaries/list | query: market_code (required), level?, boundary_type? | 200 {boundaries: [...]} |
GET | /boundaries/{locator} | - | 200 the boundary; 404 if absent |
GET | /boundaries/{locator}/geometry | - | 200 GeoJSON (application/geo+json) |
GET | /boundaries/{locator}/children | - | 200 {boundaries: [...]} |
GET | /boundaries/{locator}/ancestors | - | 200 {boundaries: [...]} (recursive walk up parent_id) |
DELETE | /boundaries/{locator} | - | 204. Soft-delete: sets effective_to = CURRENT_DATE. No rate-table reference check. |
Boundary groups
| Method | Path | Body / params | Response |
|---|---|---|---|
POST | /boundary-groups | {code, marketCode, description?} | 201 the group |
GET | /boundary-groups/list | query: market_code (required) | 200 {groups: [...]} |
GET | /boundary-groups/{code} | - | 200 the group; 404 if absent |
PUT | /boundary-groups/{code}/members | {boundaryIds: [uuid...]} | 204. Replaces the full membership set. |
GET | /boundary-groups/{code}/members | - | 200 {boundaries: [...]} |
Spatial queries (JWT)
| Method | Path | Body | Response |
|---|---|---|---|
POST | /spatial/contains | {boundaryId, lat, lng} | 200 {contained: bool} |
POST | /spatial/lookup | {marketCode, lat, lng, level?, groupCode?} | If level set: the boundary at that level. Else if groupCode set: the boundary in that group. Else: {boundaries: [...]} for all containing boundaries. |
POST | /spatial/serviceability | {marketCode, lat, lng} | 200 {serviceable: bool} |
/spatial/lookup does not detect ambiguity or return 422: if a group is not partition-clean, the underlying query simply LIMIT 1s and returns the first match. There is no /spatial/nearest endpoint, no POST /boundaries/{locator}/groups, and no PUT /boundaries/{locator}/geometry.
Internal (service-to-service, unauthenticated)
| Method | Path | Query params | Response |
|---|---|---|---|
GET | /internal/spatial/boundary-at-level | market_code, level, lat, lng | 200 the boundary; 404 if none |
GET | /internal/spatial/boundary-in-group | group_code, lat, lng | 200 the boundary; 404 if none |
Database schema
Schema policy_admin. Migrations 0014-0018 (boundary tables + PostGIS) on feat-boundary-service; 0020 (rate_tables) on feat-rating-engine.
boundary_levels (0015)
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
market_code | text not null | |
level | int not null | 0=country, 1=region, ... (convention) |
name | text not null | display |
parent_level | int | nullable |
created_at | timestamptz | NOW() |
Unique: (market_code, level). There is no separate locator on levels.
boundaries (0016)
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
locator | text not null unique | BND-... |
market_code | text not null | |
level | int | nullable |
parent_id | UUID | self-FK to boundaries.id |
code | text not null | |
name | text not null | |
boundary_type | text not null | CHECK: ADMINISTRATIVE / SERVICE_AREA / RATING_ZONE / SCHEME_REGION / EXCLUSION_ZONE |
geometry | GEOMETRY(MULTIPOLYGON, 4326) | the shape; not mapped to Go |
properties | jsonb not null default {} | |
effective_from | date not null default CURRENT_DATE | |
effective_to | date | NULL = active |
created_at | timestamptz | NOW() |
Indexes: GIST on geometry; btree on (market_code, level), parent_id, code, boundary_type. There are no lat, lng, or centroid columns and no centroid trigger on this table (see Party location below).
boundary_groups and boundary_group_members (0017)
boundary_groups: {id UUID PK, locator text unique, code text unique, market_code text, description text, created_at}. No partition flag.
boundary_group_members: {boundary_group_id UUID, boundary_id UUID}, PK (boundary_group_id, boundary_id), both FKs ON DELETE CASCADE.
rate_tables (rating engine, 0020)
Not owned by Boundary. Columns: {id UUID, locator text, product_version_id UUID FK→product_versions, rating_factor_id UUID FK→rating_factors, element_type text, key text, range_min numeric(14,4), range_max numeric(14,4), value numeric(14,4), value_type text CHECK('BASE_RATE','MULTIPLIER'), effective_from, effective_to, created_at}. No boundary_locator, no multiplier, no FK to boundaries.
Party location trigger
The only centroid-style trigger is on parties, not boundaries (migration 0018). The parties table gains lat/lng (DOUBLE PRECISION) plus a location GEOGRAPHY(POINT, 4326) column. A BEFORE INSERT OR UPDATE OF lat, lng trigger (sync_party_location) keeps location in sync via ST_SetSRID(ST_MakePoint(lng, lat), 4326), with a GIST index on location. This is how a member's point gets stored for later containment checks.
Seeding
Bulk-load boundaries with the import endpoint, which takes a GeoJSON FeatureCollection; each feature becomes one boundaries row (using its properties.code / properties.name, falling back to generated values). The handler iterates features in one request, but it is not wrapped in a single transaction: a mid-list failure returns the count imported so far.
curl -X POST https://api.dev.hiolly.com/boundaries/import \
-H "Authorization: Bearer ${JWT}" \
-H "Content-Type: application/json" \
-d '{"marketCode":"GB","level":2,"boundaryType":"ADMINISTRATIVE","featureCollection":{...}}'
# → {"imported": 348}Dependencies
| Dependency | Purpose | Failure mode |
|---|---|---|
Postgres policy_admin with postgis extension | Spatial storage and all ST_* queries | Hard fail |
| Keycloak (JWT validation) | External route auth | Hard fail for external routes; internal routes are unauthenticated |
| OTel collector | Tracing (every service method opens a span) | Degraded: traces lost, queries continue |
Invariants
- Boundaries are soft-versioned via
effective_from/effective_to; delete setseffective_to = CURRENT_DATErather than removing the row. Spatial lookups filter oneffective_from <= CURRENT_DATE AND (effective_to IS NULL OR effective_to > CURRENT_DATE). - The GIST index on
geometryis load-bearing: without it everyST_Containsis a full-table scan. IsServiceable= inside someSERVICE_AREAAND inside noEXCLUSION_ZONE, both filtered to the same market and to currently-effective rows.- Group membership is replace-the-set:
PUT /boundary-groups/{code}/membersdeletes existing members and re-inserts in one transaction. Ancestorswalksparent_idrecursively and excludes the starting boundary;Childrenreturns directparent_idmatches only.
Caveats
- Not merged. See the banner. Mainline has none of this code.
- No role-based authorization. External mutations need only a valid JWT, not a
boundary-adminrole. Internal endpoints have no auth at all and depend on network isolation. - No partition enforcement and no ambiguity errors.
/spatial/lookupagainst a group with overlapping members returns one arbitrary match (LIMIT 1), not a 422. Partition-cleanliness is the operator's responsibility, not the database's. - No nearest-neighbour. Any "nearest provider / nearest by specialty" framing is unbuilt; the
Point/NearestResulttypes are dead code. - Import is not atomic. A failed feature aborts the loop and leaves earlier features committed.
- Not a general-purpose GIS. No geocoding (use an external geocoder in the caller), no routing or driving distance.
