Adding Jurisdiction Packs
Source: docs/compliance/adding-jurisdiction-packs.md
# Adding New Jurisdiction Packs
This guide walks through the process of adding a new jurisdiction pack to the Compliance Navigator. A jurisdiction pack consists of source data, an ingestion script, and the associated tests and UI integration.
## Pattern Overview
Every jurisdiction pack follows the same structure:
```
data/sources/<pack-name>/
<pack-name>-obligations.json # Obligation snapshot (or eligibility criteria)
<pack-name>-rules.json # Rule definitions (commodity-based packs only)
<pack-name>-meta.json # Source URLs, version, SHA-256 checksums
scripts/
ingest-<pack-name>.ts # Ingestion script
packages/modules/trade/src/compliance/
<evaluator>.ts # Custom evaluator (entity-based packs only)
```
Before starting, decide which evaluation model your pack uses:
| Model | When to Use | Examples |
|-------|-------------|---------|
| **Commodity-based** | Obligations depend on what is being imported (HS code, product category, origin) | APHIS CORE |
| **Entity-based** | Obligations depend on who is importing (importer status, carrier registration, trader certifications) | CBSA CSA |
Commodity-based packs use the shared `evaluateObligations()` function. Entity-based packs require a dedicated evaluator function (like `evaluateCsaEligibility()`).
## Step 1: Create Data Source Files
Create a new directory under `data/sources/<pack-name>/`.
### Obligations JSON Snapshot
For **commodity-based** packs, create `<pack-name>-obligations.json`:
```json
{
"version": "<pack-name>-v1.0",
"source": "Human-readable source description",
"obligations": [
{
"obligationCode": "AUTHORITY-PROGRAM-NNN",
"obligationName": "Human-readable name",
"description": "What this obligation requires",
"jurisdictionCode": "XX",
"authorityCode": "AUTHORITY",
"programCode": "PROGRAM",
"requiredDocuments": ["DOC_TYPE_CODE"],
"hsPrefixes": ["0603"],
"productCategories": ["cut_flowers"],
"originCountries": ["*"],
"criteria": null,
"sourceSection": "Source document, Chapter N, pp. XX-YY",
"status": "ACTIVE",
"effectiveFrom": "2024-01-01T00:00:00.000Z",
"effectiveTo": null
}
]
}
```
For **entity-based** packs, create `<pack-name>-eligibility.json`:
```json
{
"program": {
"code": "PROGRAM",
"name": "Program Name",
"jurisdiction": "XX",
"authority": "AUTHORITY",
"description": "Program description"
},
"eligibilityCriteria": [
{
"entityType": "IMPORTER",
"obligationCode": "AUTHORITY-PROGRAM-ENTITY-CHECK",
"name": "Human-readable name",
"description": "What this check verifies",
"requirements": [
{ "field": "profileFieldName", "operator": "equals", "value": "EXPECTED" }
],
"requiredDocuments": ["DOC_TYPE_CODE"],
"sourceSection": "Source Guide, Chapter N"
}
]
}
```
Supported operators for entity-based requirements: `equals`, `after` (date comparison against `NOW`), `gte` (numeric greater-than-or-equal).
### Rules JSON Snapshot (Commodity-Based Only)
Create `<pack-name>-rules.json`:
```json
{
"version": "<pack-name>-v1.0",
"rules": [
{
"ruleCode": "AUTHORITY_PROGRAM_NNN",
"version": "YYYY.MM",
"obligationCode": "AUTHORITY-PROGRAM-NNN",
"module": "trade",
"scopeType": "CORRIDOR",
"scopeOrigin": ["*"],
"scopeDestination": ["XX"],
"priority": 90,
"enabled": true,
"level": "country"
}
]
}
```
Each rule must reference an existing `obligationCode` from the obligations file. Valid scope types: `UNIVERSAL`, `ORIGIN`, `DESTINATION`, `CORRIDOR`. Valid modules: `ship`, `trade`, `product`, `finance`.
### Metadata JSON
Create `<pack-name>-meta.json`:
```json
{
"version": "<pack-name>-v1.0",
"sourceLabel": "Human-readable source description",
"sourceUrls": [
"https://official-source-url.gov/"
],
"extractedAt": "2026-01-15T00:00:00Z",
"obligationsSha256": "<sha256 of obligations JSON file>",
"rulesSha256": "<sha256 of rules JSON file>"
}
```
For entity-based packs, use `sha256` instead of separate obligation/rule checksums:
```json
{
"source": "AUTHORITY-PROGRAM",
"version": "YYYY-QN",
"description": "Pack description",
"urls": ["https://official-source-url.gov/"],
"extractedAt": "2026-01-15T00:00:00Z",
"sha256": "<sha256 of eligibility JSON file>"
}
```
Generate checksums with:
```bash
shasum -a 256 data/sources/<pack-name>/<pack-name>-obligations.json
shasum -a 256 data/sources/<pack-name>/<pack-name>-rules.json
```
## Step 2: Write Ingestion Script
Create `scripts/ingest-<pack-name>.ts`. Follow the patterns in the existing scripts.
### Required Behaviors
1. **Checksum validation**: Read the meta file, compute SHA-256 of each data file, and compare against the expected checksums. Fail immediately on mismatch.
2. **Structure validation**: Verify all required fields are present, check for duplicate obligation codes, validate authority/status/scope values.
3. **Transaction wrapping**: All database writes must happen inside `prisma.$transaction()`. Either all writes succeed or none do.
4. **Provenance records**: For each write, create corresponding entries:
- `DataSource` (upsert by code)
- `DataRelease` (upsert by sourceId + version)
- `ComplianceObligation` (upsert by obligationCode)
- `RuleDefinition` (find + update/create for nullable tenantId)
- `DataChangeLog` (create for every obligation and rule upsert)
5. **Content hashing**: Compute SHA-256 of the normalized obligation payload and store in `contentHash`. Use `stableStringify()` (deterministic key ordering) to ensure consistent hashes.
6. **`--dry-run` flag**: When set, validate and print what would be written, but skip all database operations.
### Script Template
```typescript
#!/usr/bin/env node
import { createHash } from 'crypto'
import { readFile } from 'fs/promises'
import path from 'path'
// ... type definitions ...
function stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
const entries = Object.entries(value as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`
}
function sha256Hex(input: string): string {
return createHash('sha256').update(input, 'utf8').digest('hex')
}
async function main(): Promise<number> {
const options = parseArgs(process.argv.slice(2))
// 1. Read source files
// 2. Validate checksums
// 3. Validate structure
// 4. If --dry-run, print summary and exit
// 5. Import prisma (lazy, so --dry-run doesn't need a DB)
// 6. prisma.$transaction() with all upserts + changelog entries
return 0
}
main()
.then((code) => process.exit(code))
.catch((error: unknown) => {
console.error(`Ingest failed: ${error instanceof Error ? error.message : String(error)}`)
process.exit(2)
})
```
### Key Implementation Notes
- Import `prisma` lazily (inside the non-dry-run branch) so that `--dry-run` works without a database connection.
- The `changedBy` field on `DataChangeLog` should be `system:ingest-<pack-name>`.
- For entity-based packs, set `hsPrefixes`, `productCategories`, and `originCountries` to empty arrays on the `ComplianceObligation` record, and store the entity criteria in the `criteria` JSON column.
## Step 3: Add Contract Tests
### Compliance Check Returns Correct Obligations
Test that `POST /api/compliance/check` returns the expected obligations for known inputs:
```typescript
it('returns APHIS APQ obligations for cut flowers from Colombia', async () => {
const res = await request(app)
.post('/api/compliance/check')
.set('Authorization', `Bearer ${token}`)
.send({
hsCode: '0603.11',
originCountry: 'CO',
destinationCountry: 'US',
productCategory: 'cut_flowers',
})
expect(res.status).toBe(200)
expect(res.body.obligations).toEqual(
expect.arrayContaining([
expect.objectContaining({
obligationCode: 'APHIS-APQ-CORE-301',
}),
])
)
})
```
### Citation Metadata Present on All Results
Every obligation in the response must include the citation block:
```typescript
it('includes citation metadata on every obligation', async () => {
const res = await request(app)
.post('/api/compliance/check')
.send({ hsCode: '0603', originCountry: 'CO' })
for (const obligation of res.body.obligations) {
expect(obligation.citation).toEqual(
expect.objectContaining({
source: expect.any(String),
sourceSection: expect.any(String),
releaseId: expect.any(String),
contentHash: expect.any(String),
})
)
}
})
```
### Tenant Isolation (Entity-Based Packs)
For entity-based packs like CSA, verify that one tenant's compliance profile does not leak into another tenant's evaluation:
```typescript
it('evaluates CSA eligibility against the requesting tenant profile only', async () => {
// Tenant A has a complete CSA profile
// Tenant B has no CSA profile
// Evaluate for Tenant B -> should get overallEligible: false
})
```
### Ingestion Dry-Run
Test that `--dry-run` exits cleanly without database writes:
```typescript
it('dry-run validates without writing', async () => {
const result = execSync(
'npx tsx scripts/ingest-<pack-name>.ts --dry-run',
{ encoding: 'utf8' }
)
expect(result).toContain('Dry-run complete')
})
```
## Step 4: Add to UI
### Jurisdiction Tab
Add a new tab to the compliance navigator page in `apps/web/src/app/trade/compliance/`:
1. Add the jurisdiction to the tab list (e.g., "EU CCC" alongside "US APHIS" and "CA CSA").
2. Wire the tab to filter obligations by `jurisdiction` query parameter.
3. Add authority filter options for the new jurisdiction's authorities.
### Obligation Display
For commodity-based packs, the existing obligation table columns (HS prefixes, product categories, required documents) work as-is.
For entity-based packs, the UI shows entity type grouping, requirement checks, and gap analysis instead of HS/category columns.
### Web Proxy Routes
If adding new API endpoints beyond the existing four compliance routes, use `createProxyHandler`:
```typescript
export const GET = createProxyHandler({
method: 'GET',
path: '/api/compliance/<new-endpoint>',
passQueryString: true,
})
```
## New Pack Readiness Checklist
Before merging a new jurisdiction pack:
- [ ] **Source data files** created in `data/sources/<pack-name>/` with valid checksums
- [ ] **Meta file** includes source URLs, version, extraction date, and SHA-256 checksums
- [ ] **Ingestion script** validates checksums, uses `prisma.$transaction()`, supports `--dry-run`
- [ ] **Ingestion dry-run passes**: `npx tsx scripts/ingest-<pack-name>.ts --dry-run`
- [ ] **DataSource, DataRelease, ComplianceObligation, RuleDefinition** records upserted correctly
- [ ] **DataChangeLog** entries created for every upsert
- [ ] **Content hashes** computed with `stableStringify()` for deterministic ordering
- [ ] **Contract tests** pass: correct obligations returned, citation metadata present, tenant isolation verified
- [ ] **`POST /api/compliance/check`** returns new pack's obligations for matching inputs
- [ ] **`GET /api/compliance/obligations?jurisdiction=XX`** lists new pack's obligations
- [ ] **UI tab** added to compliance navigator page
- [ ] **Authority filter** options updated for new jurisdiction
- [ ] **All existing tests still pass**: `pnpm test`
- [ ] **TypeScript compiles**: `npx tsc --noEmit`
- [ ] **Linter passes**: `pnpm lint`